【Unity】動的に枠を描く話

はじめに

こんにちは。ソーシャルゲーム事業部の中山です。 この記事はカヤックUnityアドベントカレンダー2018の6日目の記事です。 今回はUnityで枠を描くときに使える話です。

画面の中に枠を描きたいというとき、枠ごとに専用の素材を用意してしまうと、ゲームの容量が無駄に大きくなったり、描画の負荷が高くなったり、無駄にメモリを消費したりします。 長方形の枠を描くだけであればUnity標準の機能でなんとかできる*1のですが、形が歪んだ枠を何個も描きたい場合はどうすれば良いでしょうか、というのが今回の話です。

実例

tokyo-prison

これは東京プリズンというゲームのスクリーンショットです。 画面右に大きな枠が描かれていますが、 もともとこの枠は画像を使って描かれていて、UnityのSceneウィンドウで表示をOverdrawに切り替えると以下のようになっていました。

tokyo-prison-overdraw

これだけ見ても何がいけないのかわからないかと思いますが、今回紹介する方法で枠を描くようにすると以下のようになります。

tokyo-prison-overdraw2

全体的に少し暗くなっているのがわかるかと思います。 Overdrawは描画の負荷が高いところほど表示が明るくなるというものなので、全体的に少し負荷が下がったということになります。 枠の素材は大部分が透明なのですが、uGUIのImageコンポーネントはそんなこと関係なしに素材の全体を描画しようとするので、単純に素材を置いて枠を描いてしまうと大きな無駄が発生することになります*2

また、枠の画像のサイズは597×676なので、素材をロードするとそれだけでメモリを約1.5MB消費することになります*3。 今回紹介する方法を使えば素材をロードしなくて済むので、この分のメモリの消費を抑えられるということになります。

このゲームのバトル画面は描画しないといけないものが多いので、できるだけ低スペックなマシンでも快適にプレイできるようにするには、このようなパフォーマンスチューニングが重要になってきます。

適当な例

waku

ここではこのような枠を描くことを考えます。

まず描く

メッシュを動的に生成することで、専用の素材なしで枠を描くことができます。 以下のようなコンポーネントを作り、キャンバスの下の適当なGameObjectに追加すれば良いです。

using System;
using UnityEngine;
using UnityEngine.UI;

public class WakuGraphic : Graphic
{
    [Serializable]
    private struct VertexPair
    {
        public Vector2 outer;
        public Vector2 inner;
    }

    public Sprite sprite;

    [SerializeField]
    private bool _isClosed = false;

    [SerializeField]
    private VertexPair[] _vertices;

    public override Texture mainTexture
    {
        get
        {
            if (sprite != null)
            {
                return sprite.texture;
            }
            else
            {
                return base.mainTexture;
            }
        }
    }

    protected override void OnPopulateMesh(VertexHelper vh)
    {
        vh.Clear();

        if (_vertices.Length < 2)
        {
            return;
        }

        var uv = new Vector2();
        var dstSizeDelta = rectTransform.sizeDelta;

        if (sprite != null)
        {
            float offsetX = 0f;
            float offsetY = 0f;
            if (sprite.packed)
            {
                offsetX = sprite.textureRect.x;
                offsetY = sprite.textureRect.y;
            }
            uv.x = (offsetX + sprite.rect.width / 2f) / sprite.texture.width;
            uv.y = (offsetY + sprite.rect.height / 2f) / sprite.texture.height;
        }

        var outerColor = color;
        outerColor.a = 0f;

        var pos = new Vector2();

        // 座標を計算する
        for (int i = 0; i < _vertices.Length; ++i)
        {
            var v = _vertices[i];
            pos = v.outer;
            pos.x *= dstSizeDelta.x;
            pos.y *= dstSizeDelta.y;
            vh.AddVert(pos, color, uv);;

            pos = v.inner;
            pos.x *= dstSizeDelta.x;
            pos.y *= dstSizeDelta.y;
            vh.AddVert(pos, color, uv);
        }

        // 枠の辺を描く
        for (int i = 0; i < _vertices.Length - 1; ++i)
        {
            vh.AddTriangle(i * 2, i * 2 + 1, (i + 1) * 2);
            vh.AddTriangle(i * 2 + 1, (i + 1) * 2, (i + 1) * 2 + 1);
        }
        if (_isClosed)
        {
            var i = _vertices.Length - 1;
            vh.AddTriangle(0, 1, i * 2);
            vh.AddTriangle(1, i * 2 + 1, i * 2);
        }
    }
}

このコンポーネントを適当なGameObjectに追加し、以下のようにフィールドに適当な値を設定すると、先ほどの画像と同じような枠を描くことができます。

waku-component

Sceneウィンドウの表示をWireframeに切り替えると以下のようになります。

wireframe

三角形を組み合わせることで、専用の素材を使わずに枠を描いていることがわかるかと思います。

簡易的にアンチエイリアスをかける

これで一応枠が描けるようになるのですが、Gameウィンドウで見ると境界のジャギーが目立ってあまり綺麗ではありません。 アンチエイリアスをかけた方が良さそうです。

waku-zoom

ここでは、枠の外側と内側に薄くグラデーションをかけ、そのグラデーションの幅を狭くすることでアンチエイリアスの代わりにします。 Sceneウィンドウで見るとこういう感じにグラデーションがかかることになります。

waku-gradient

上のようにグラデーションをかけたものをGameウィンドウで見ると下の画像のようになり、アンチエイリアスがかかっているように見えることが確認できるかと思います。 ここではグラデーションの幅は1pxにしてあります。

waku-antialias

この機能を入れたコンポーネントのソースコードです。

using System;
using UnityEngine;
using UnityEngine.UI;

public class WakuGraphic : Graphic
{
    [Serializable]
    private struct VertexPair
    {
        public Vector2 outer;
        public Vector2 inner;
    }

    public Sprite sprite;

    [SerializeField]
    private bool _isClosed = false;

    [SerializeField]
    private float _antialiasWidth = 1f;

    [SerializeField]
    private VertexPair[] _vertices;

    public override Texture mainTexture
    {
        get
        {
            if (sprite != null)
            {
                return sprite.texture;
            }
            else
            {
                return base.mainTexture;
            }
        }
    }

    protected override void OnPopulateMesh(VertexHelper vh)
    {
        vh.Clear();

        if (_vertices.Length < 3)
        {
            return;
        }

        var uv = new Vector2();
        var dstSizeDelta = rectTransform.sizeDelta;

        if (sprite != null)
        {
            float offsetX = 0f;
            float offsetY = 0f;
            if (sprite.packed)
            {
                offsetX = sprite.textureRect.x;
                offsetY = sprite.textureRect.y;
            }

            // 適当な座標を設定しておく
            uv.x = (offsetX + sprite.rect.width / 2f) / sprite.texture.width;
            uv.y = (offsetY + sprite.rect.height / 2f) / sprite.texture.height;
        }

        var outerColor = color;
        outerColor.a = 0f;

        // 外積を使って頂点が時計回りに並んでいるのか反時計回りに並んでいるのか判定する
        bool clockwize = Cross(_vertices[1].outer - _vertices[0].outer, _vertices[2].outer - _vertices[1].outer) > 0f;

        var pos = new Vector3();
        
        // 座標を計算する
        for (int i = 0; i < _vertices.Length; ++i)
        {
            int prevIndex = (i != 0 ? i - 1 : _vertices.Length - 1);
            int nextIndex = (i != _vertices.Length - 1 ? i + 1 : 0);

            var v = _vertices[i];
            var outward = CalcOutwardVector(
                clockwize,
                ref v.outer,
                ref _vertices[prevIndex].outer,
                ref _vertices[nextIndex].outer);
            pos = v.outer;
            pos.x *= dstSizeDelta.x;
            pos.y *= dstSizeDelta.y;
            outward.x += pos.x;
            outward.y += pos.y;
            vh.AddVert(pos, color, uv);
            vh.AddVert(outward, outerColor, uv);

            outward = -CalcOutwardVector(
                clockwize,
                ref v.inner,
                ref _vertices[prevIndex].inner,
                ref _vertices[nextIndex].inner);
            pos = v.inner;
            pos.x *= dstSizeDelta.x;
            pos.y *= dstSizeDelta.y;
            outward.x += pos.x;
            outward.y += pos.y;
            vh.AddVert(pos, color, uv);
            vh.AddVert(outward, outerColor, uv);
        }

        // 枠の辺を描く
        for (int i = 0; i < _vertices.Length - 1; ++i)
        {
            vh.AddTriangle(i * 4, i * 4 + 2, (i + 1) * 4);
            vh.AddTriangle(i * 4 + 2, (i + 1) * 4, (i + 1) * 4 + 2);
            vh.AddTriangle(i * 4, i * 4 + 1, (i + 1) * 4);
            vh.AddTriangle(i * 4 + 1, (i + 1) * 4, (i + 1) * 4 + 1);
            vh.AddTriangle(i * 4 + 2, i * 4 + 3, (i + 1) * 4 + 2);
            vh.AddTriangle(i * 4 + 3, (i + 1) * 4 + 2, (i + 1) * 4 + 3);
        }

        if (_isClosed)
        {
            var i = _vertices.Length - 1;
            vh.AddTriangle(0, 2, i * 4);
            vh.AddTriangle(2, i * 4 + 2, i * 4);
            vh.AddTriangle(0, i * 4, i * 4 + 1);
            vh.AddTriangle(0, 1, i * 4 + 1);
            vh.AddTriangle(2, i * 4 + 2, i * 4 + 3);
            vh.AddTriangle(2, 3, i * 4 + 3);
        }
    }

    // ここから下が新しく増えた

    private Vector2 CalcOutwardVector(bool clockwize, ref Vector2 refVert, ref Vector2 prevVert, ref Vector2 nextVert)
    {
        var v1 = refVert - prevVert;
        var v2 = nextVert - refVert;
        v1.Normalize();
        v2.Normalize();

        var v1_ = v1;
        Rotate(ref v1_);

        var v2_ = v2;
        Rotate(ref v2_);

        float a = Mathf.Sqrt(Vector2.SqrMagnitude(v2_ - v1_) / Vector2.SqrMagnitude(v2 + v1));

        if (!clockwize)
        {
            a = -a;
        }

        return _antialiasWidth * (v1_ - v1 * a);
    }

    // 反時計回りに90度に回す
    private void Rotate(ref Vector2 v)
    {
        float a = v.x;
        v.x = -v.y;
        v.y = a;
    }

    // いわゆる外積
    private float Cross(Vector2 a, Vector2 b)
    {
        return a.x * b.y - a.y * b.x;
    }
}

CalcOutwardVector でグラデーションをかけるための計算をしています。 ここでは説明しやすい方法を使っていますが、他にもやり方はいろいろあります。 また、ここでは凸な図形のみ考慮しています。

waku-1

今見ている頂点とその隣の頂点を使って、長さ1のベクトル \boldsymbol{a}\boldsymbol{b} を作ります。 この2つのベクトルを90度回転させたものが \boldsymbol{n}_a\boldsymbol{n}_b です。 この4つのベクトルを使って、青い丸から緑の丸を指すベクトルを作ると、いい感じにグラデーションをかけられます。

青い丸から緑の丸を指すベクトルは、適当な2つの正の実数 st を使って 
  l \boldsymbol{n}_a + s \boldsymbol{a},
  \ l \boldsymbol{n}_b - t \boldsymbol{b}
と2通りで表すことができるので、

\displaystyle
  l \boldsymbol{n}_a + s \boldsymbol{a}
  = l \boldsymbol{n}_b - t \boldsymbol{b}

となります。そして、実は s=t が成り立つので次の式が得られます。

\displaystyle
  l \boldsymbol{n}_a + s \boldsymbol{a}
  = l \boldsymbol{n}_b - s \boldsymbol{b}

これを整理すると

\displaystyle
  \left\vert s \right\vert
  = l \sqrt{
    \frac{
      \left\Vert \boldsymbol{n}_b - \boldsymbol{n}_a \right\Vert^2
    }{
      \left\Vert \boldsymbol{a} + \boldsymbol{b} \right\Vert^2
    }
  }

となります。CalcOutwardVector の中で宣言されている a\left\vert s\right\vertに相当します。

位置の調整を楽にする

ここまでで一応いい感じに枠が描けるようになったのですが、座標を一つ一つ手で入力しないといけないので位置の調整が面倒です。 ハンドルというものをつけるとマウスで調整できて便利なのでやっておきましょう。

ハンドルをつけるとこういう感じで座標を設定できるようになります。

waku-mouse

この機能を入れたコンポーネントのソースコードです。

using System;
using UnityEngine;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class WakuGraphic : Graphic
{
    [Serializable]
    private struct VertexPair
    {
        public Vector2 outer;
        public Vector2 inner;
    }

    public Sprite sprite;

    [SerializeField]
    private bool _isClosed = false;

    [SerializeField]
    private float _antialiasWidth = 1f;

    [SerializeField]
    private VertexPair[] _vertices;

    public override Texture mainTexture
    {
        get
        {
            if (sprite != null)
            {
                return sprite.texture;
            }
            else
            {
                return base.mainTexture;
            }
        }
    }

    protected override void OnPopulateMesh(VertexHelper vh)
    {
        vh.Clear();

        if (_vertices.Length < 3)
        {
            return;
        }

        var uv = new Vector2();
        var dstSizeDelta = rectTransform.sizeDelta;

        if (sprite != null)
        {
            float offsetX = 0f;
            float offsetY = 0f;
            if (sprite.packed)
            {
                offsetX = sprite.textureRect.x;
                offsetY = sprite.textureRect.y;
            }

            // 適当な座標を設定しておく
            uv.x = (offsetX + sprite.rect.width / 2f) / sprite.texture.width;
            uv.y = (offsetY + sprite.rect.height / 2f) / sprite.texture.height;
        }

        var outerColor = color;
        outerColor.a = 0f;

        // 外積を使って頂点が時計回りに並んでいるのか反時計回りに並んでいるのか判定する
        bool clockwize = Cross(_vertices[1].outer - _vertices[0].outer, _vertices[2].outer - _vertices[1].outer) > 0f;

        var pos = new Vector3();
        
        // 座標を計算する
        for (int i = 0; i < _vertices.Length; ++i)
        {
            int prevIndex = (i != 0 ? i - 1 : _vertices.Length - 1);
            int nextIndex = (i != _vertices.Length - 1 ? i + 1 : 0);

            var v = _vertices[i];
            var outward = CalcOutwardVector(
                clockwize,
                ref v.outer,
                ref _vertices[prevIndex].outer,
                ref _vertices[nextIndex].outer);
            pos = v.outer;
            pos.x *= dstSizeDelta.x;
            pos.y *= dstSizeDelta.y;
            outward.x += pos.x;
            outward.y += pos.y;
            vh.AddVert(pos, color, uv);
            vh.AddVert(outward, outerColor, uv);

            outward = CalcOutwardVector(
                clockwize,
                ref v.inner,
                ref _vertices[prevIndex].inner,
                ref _vertices[nextIndex].inner);
            pos = v.inner;
            pos.x *= dstSizeDelta.x;
            pos.y *= dstSizeDelta.y;
            outward.x += pos.x;
            outward.y += pos.y;
            vh.AddVert(pos, color, uv);
            vh.AddVert(outward, outerColor, uv);
        }

        // 枠の辺を描く
        for (int i = 0; i < _vertices.Length - 1; ++i)
        {
            vh.AddTriangle(i * 4, i * 4 + 2, (i + 1) * 4);
            vh.AddTriangle(i * 4 + 2, (i + 1) * 4, (i + 1) * 4 + 2);
            vh.AddTriangle(i * 4, i * 4 + 1, (i + 1) * 4);
            vh.AddTriangle(i * 4 + 1, (i + 1) * 4, (i + 1) * 4 + 1);
            vh.AddTriangle(i * 4 + 2, i * 4 + 3, (i + 1) * 4 + 2);
            vh.AddTriangle(i * 4 + 3, (i + 1) * 4 + 2, (i + 1) * 4 + 3);
        }

        if (_isClosed)
        {
            var i = _vertices.Length - 1;
            vh.AddTriangle(0, 2, i * 4);
            vh.AddTriangle(2, i * 4 + 2, i * 4);
            vh.AddTriangle(0, i * 4, i * 4 + 1);
            vh.AddTriangle(0, 1, i * 4 + 1);
            vh.AddTriangle(2, i * 4 + 2, i * 4 + 3);
            vh.AddTriangle(2, 3, i * 4 + 3);
        }
    }

    private Vector2 CalcOutwardVector(bool clockwize, ref Vector2 refVert, ref Vector2 prevVert, ref Vector2 nextVert)
    {
        var v1 = refVert - prevVert;
        var v2 = nextVert - refVert;
        v1.Normalize();
        v2.Normalize();

        var v1_ = v1;
        Rotate(ref v1_);

        var v2_ = v2;
        Rotate(ref v2_);

        float a = Mathf.Sqrt(Vector2.SqrMagnitude(v2_ - v1_) / Vector2.SqrMagnitude(v2 + v1));

        if (!clockwize)
        {
            a = -a;
        }

        return _antialiasWidth * (v1_ - v1 * a);
    }

    // 反時計回りに90度に回す
    private void Rotate(ref Vector2 v)
    {
        float a = v.x;
        v.x = -v.y;
        v.y = a;
    }

    // いわゆる外積
    private float Cross(Vector2 a, Vector2 b)
    {
        return a.x * b.y - a.y * b.x;
    }

    // ここから下が新しく増えた

#if UNITY_EDITOR
    [CustomEditor(typeof(WakuGraphic), true)]
    public class WakuGraphicInspector : Editor
    {
        private void OnSceneGUI()
        {
            Tools.current = Tool.None;

            var component = target as WakuGraphic;
            var vertices = component._vertices;
            if (vertices != null && vertices.Length >= 2)
            {
                var rectTransform = component.rectTransform;
                var sizeDelta = rectTransform.sizeDelta;
                var pivot = rectTransform.pivot;
                var mat = rectTransform.localToWorldMatrix;
                var inv = rectTransform.worldToLocalMatrix;

                for (int i = 0; i < vertices.Length; ++i)
                {
                    var v = vertices[i].outer;

                    // [0, 1]に正規化した座標からローカル座標を計算する
                    v.x *= sizeDelta.x;
                    v.y *= sizeDelta.y;

                    // ローカル座標からワールド座標に変換する
                    var currentPosition = mat.MultiplyPoint(v);
                    PositionHandle(ref vertices[i].outer, ref inv, ref currentPosition, ref sizeDelta);

                    v = vertices[i].inner;

                    // ローカル座標を計算する
                    v.x *= sizeDelta.x;
                    v.y *= sizeDelta.y;

                    // ローカル座標からワールド座標に変換する
                    currentPosition = mat.MultiplyPoint(v);
                    PositionHandle(ref vertices[i].inner, ref inv, ref currentPosition, ref sizeDelta);
                }

                component.SetVerticesDirty();
            }
        }

        void PositionHandle(ref Vector2 targetPoint, ref Matrix4x4 inverse, ref Vector3 position, ref Vector2 sizeDelta)
        {
            var handleSize = HandleUtility.GetHandleSize(position) * 0.2f;
            var newWorldPosition = Handles.FreeMoveHandle(position, Quaternion.identity, handleSize, new Vector3(1f, 1f, 0f), Handles.CircleHandleCap);

            // ワールド座標からローカル座標に戻す
            var newPosition = inverse.MultiplyPoint3x4(newWorldPosition);

            // [0, 1]に正規化した座標に戻す
            newPosition.x /= sizeDelta.x;
            newPosition.y /= sizeDelta.y;

            if (Mathf.Abs(newPosition.x - targetPoint.x) > 1e-5f)
            {
                targetPoint.x = newPosition.x;
            }
            if (Mathf.Abs(newPosition.y - targetPoint.y) > 1e-5f)
            {
                targetPoint.y = newPosition.y;
            }
        }
    }
#endif
}

下の方の WakuGraphicInspector クラスがハンドルをつけるためのクラスで、 PositionHandle がハンドルを置くためのメソッドです。

Handles.FreeMoveHandle の1つ目の引数で指定した座標にハンドルが表示され、ハンドルを動かすと動かした後の座標が返ってきます。 Handles.FreeMoveHandle に渡す座標はワールド座標ですが、枠の頂点の位置はローカル座標(に相当するもの)で保存する必要があるので、 Transform.localToWorldMatrixTransform.worldToLocalMatrix を使ってローカル座標とワールド座標の間の変換をしています。
if (Mathf.Abs(newPosition.x - targetPoint.x) > 1e-5f)
のif文は、座標を変換するときの誤差を無視するために入れてあります。

応用

枠の頂点の座標を自分で計算しているので、画像を用いて枠を作っている場合にはできないようなことができます。 例えば、実行時に枠を変形させられます。

waku-perturb

頑張ればこういう演出も作れるでしょう。

初音ミク Project mirai 2 OP曲『アゲアゲアゲイン』フル ver.PV

他には何ができるか考えてみると良いかもしれません。

最後に

明日はながたさんによる「ComputeShaderによるVectorField計算」です。

*1:spriteをスライスして、ImageコンポーネントのImage TypeをSlicedにすれば良いです

*2:SpriteRendererを使えば削減できますが、uGUIと組み合わせるのは手間がかかります

*3:RGBA32の場合