SpriteのPhysics Shapeを使ってuGUIで自由な形の当たり判定を作る

本記事は [QualiArts Advent Calender 2019](https://qiita.com/advent-calendar/2019/qualiarts) 11日目の記事です。

昨日は @tmana さんの [Unityで3Dモデルを画像書き出しする](https://qiita.com/tmana/items/8e9eb1abe385eb32d329) でした。
明日は @tm8r さんの TAが3Dのワークフローを改善した話 です。

# はじめに
uGUIでボタンなどを作るとき、クリックなどの当たり判定の領域は普通は長方形領域になります。
しかし、ボタンの見た目によっては長方形領域に当たり判定があると不自然になってしまうため、何らかの方法で見た目に相応しい領域を当たり判定にしたくなります。
今回、Spriteの機能の1つであるPhysics Shapeを使うことで任意の多角形領域を当たり判定として定義できたので紹介します。

# デモ
今回作成したコンポーネントのデモが次のgifです。

![cross_button.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/d04994f9-4f09-90ec-d6e6-175a6678ee52.gif)

このデモでは、マウスポインターがバツマークのような画像の上にホバーしたときに画像が赤色になるようになっています。
このデモはホバーの例でしたが、クリック判定なども同じ領域のみ有効になります。

# uGUIのイベントの当たり判定の仕組み

本題に入る前に、まずはuGUIのイベントの当たり判定の仕組みについてざっくりと説明します。

uGUIでは、画面タッチやマウスによるイベントが発生したとき、GraphicRaycasterによってイベントを送り込む対象を絞り込みます。
このとき、与えられた点を矩形の中に含むGraphicの中で、Graphicに定義されているRaycastメソッドがtrueを返すもののみが対象になります。

GraphicクラスのRaycastメソッドの定義を見ると、親をたどりながらICanvasRaycastFilterコンポーネントを探していき、その中でIsRaycastLocationValidがfalseを返すものがあればRaycastメソッドもfalseを返す、という実装になっていることが分かります。

タッチやマウスのイベントが発生したときにイベントを送り込む対象を選ぶフローを示した概略図は次の通りです。

<img width="969" alt="スクリーンショット 2019-11-28 18.25.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/18ba9c36-2701-a044-6875-0d99b970c891.png">

以上より、GraphicのRaycastメソッドをoverrideしてカスタマイズするか、独自のICanvasRaycastFilterを定義すれば、uGUIにおける当たり判定をカスタマイズできそうです。


# ImageのalphaHitTestMinimumThreshold
すごくマイナーだと思いますが、uGUIのImageコンポーネントにはalphaHitTestMinimumThresholdというプロパティがあります（[公式ドキュメント](https://docs.unity3d.com/2019.1/Documentation/ScriptReference/UI.Image-alphaHitTestMinimumThreshold.html)）。
このプロパティはまさにImageクラスに定義されているIsRaycastLocationValidの中で利用されています（Image自体もICanvasRaycastFilterということです）。
ImageのIsRaycastLocationValidの中では、Raycastされた点に対応する画像のピクセルのアルファ値がalphaHitTestMinimumThreshold未満ならばfalseが返ります。
つまり、この値を0.5などに設定すれば、画像の中で透明ではない部分だけが当たり判定を持つようなことを実現できるのです。

これはかなり良さそうな機能で、これでやりたいことがカバーできるようにも思えます。
しかし、いくつかの欠点があります。

まず、スクリプトからテクスチャのピクセルを取得するため、テクスチャのRead/Write EnabledをONにする必要があります。
仕方ないといえば仕方ないですが、これでテクスチャアセットに必要なメモリ量は倍になってしまいます。

さらに困ったことに、この機能はアトラスへのパッキングに対応していません。
Spriteのuv値を使っていないので、SpriteAtlasを使ってアトラス化するとおかしなピクセルを取得してしまい、うまく動かなくなってしまいます。

# SpriteのPhysics Shape

前述のようにalphaHitTestMinimumThresholdにはいくつか欠点があったので、他の方法で自由な当たり判定を得られないかというのを考えました。
当たり判定が有効な多角形領域さえ良い感じに定義できて利用できれば、かなり自由度高く当たり判定をカスタマイズできるはずです。

そこで、SpriteのPhysics Shapeを使うことを思いつきました。
Physics ShapeとはUnity 2017で導入された機能で、Spriteに対するColliderの形を定義するための機能です。
Physics Shapeを使う利点は次のとおりです。

- Sprite Editorという使いやすいエディターが備わっている
- スクリプトから多角形情報を取得可能
- アトラス化しても情報が保持される

Sprite Editorでは、下図のようにPhysics Shapeを編集できます。
画像の上で直感的に多角形を定義できます。

<img width="534" alt="スクリーンショット 2019-11-28 18.09.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/df4eae75-eff4-ea24-5a30-5b701fb82c8f.png">


このような利点があったので、Physics Shapeを使った当たり判定制御の実装に取り掛かりました。

# ICanvasRaycastFilterの実装
今回は当たり判定をカスタマイズするためにICanvasRaycastFilterを実装したコンポーネントを実装することにしました。
Imageを継承してRaycastをoverrideするという方式も考えられますが、ICanvasRaycastFilter方式のほうがカスタマイズを有効にするかどうかを簡単に切り替え可能だろうと考えたからです。

ICanvasRaycastFilterを実装したスクリプトの雛形は次のようになります。

```csharp:PhysicsShapeRaycastFilter.cs
[RequireComponent(typeof(Image))]
public class PhysicsShapeRaycastFilter : MonoBehaviour, ICanvasRaycastFilter
{
    public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
    {
        // ここでPhysics Shape取得→screenPointが多角形内に含まれるかを判定する
        // trueを返せば当たり判定有効、falseを返せば当たり判定無効を意味する
        return false;
    }
}
```

screenPointはイベントが発生した座標、eventCameraはイベントを発生させたカメラです。

# スクリプトからのPhysics Shape取得

あとはPhysics Shapeを取得してscreenPointがその多角形内に含まれるかを判定できればやりたいことは終了です。
Physics Shapeを取得するためにはSpriteクラスのGetPhysicsShapeメソッド（[公式ドキュメント](https://docs.unity3d.com/2019.1/Documentation/ScriptReference/Sprite.GetPhysicsShape.html)）を使って次のように記述します。

```csharp
// Physics Shapeの数を取得
var physicsShapeCount = sprite.GetPhysicsShapeCount();
List<Vector2> verts = new List<Vector2>();
for (var i = 0; i < physicsShapeCount; i++)
{
    // i番目のPhysics Shpeを取得
    sprite.GetPhysicsShape(i, verts);
}
```

このように、Physics Shapeは1つのSprite内に複数定義される場合があります。
vertsには多角形の頂点のリストが入ります。
vertsのj番目とj+1番目の頂点はつながっており、最後の頂点と最初の頂点もつながっています。

# スクリーン座標をPhysics Shapeの空間と同じにする
スクリーン座標とPhysics Shapeで当たり判定を行うには、両者を同じ座標空間にしてあげる必要があります。

Physics Shapeの空間はドキュメントなどには何も書かれていなそうですが、どうやら画像の中央を原点とした”ワールド空間”のようです。
ピクセルから”ワールド空間”への変換にはSpriteのプロパティであるpixelsPerUnitが使えます。

以上を踏まえ、スクリーン座標screenPointをPhysics Shapeの空間の座標に変換するのは次のように実現できます。

```csharp
// まずは自身のローカル空間に変換
Vector2 local;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local))
{
    // 対応するローカル座標を算出不能
    return false;
}

var rect = rectTransform.rect;
var pivot = rectTransform.pivot;

// RectTransformのpivotも意識しつつ画像の中央を原点としたワールド空間に変換
var x = (local.x / rect.width + pivot.x - 0.5f) * sprite.rect.width / sprite.pixelsPerUnit;
var y = (local.y / rect.height + pivot.y - 0.5f) * sprite.rect.height / sprite.pixelsPerUnit;

var p = new Vector2(x, y);
```

こうして得られたpはPhysics Shapeと同じ空間におけるscreenPointの座標ということになります。

# 点と多角形の当たり判定
点と（凸とは限らない）多角形の当たり判定のアルゴリズムとしては有名なものが2種類あるようです。
参考 : [点と凹多角形の内外判定を行う - e.blog](http://edom18.hateblo.jp/entry/2018/11/28/200032)
今回は頑健で高速そうだったため、上記記事でいう「Crossing Number Algorithm」を採用しました。

自分は学生時代競技プログラミングをしていたのですが、そのとき非常にお世話になった[Spaghetti Sourceの実装](http://www.prefield.com/algorithm/geometry/contains.html)を参考にしてC#で次のように実装しました。

```csharp
/// <summary>
/// 非凸多角形の内部に点が存在するかどうか
/// </summary>
private bool IsInPolygon(List<Vector2> polygon, Vector2 p)
{
    // pからx軸の正方向への無限な半直線を考えて、多角形との交差回数によって判定する
    var n = polygon.Count;
    var isIn = false;
    for (var i = 0; i < n; i++)
    {
        var nxt = i + 1;
        if (nxt >= n) nxt = 0;
        var a = polygon[i] - p;
        var b = polygon[nxt] - p;
        if (a.y > b.y)
        {
            // swap
            var t = a;
            a = b;
            b = t;
        }

        if (a.y <= 0 && 0 < b.y && CrossProduct(a, b) > 0)
        {
            isIn = !isIn;
        }
    }

    return isIn;
}

/// <summary>
/// 外積
/// </summary>
private static float CrossProduct(Vector2 u, Vector2 v)
{
    return u.x * v.y - u.y * v.x;
}
```

このアルゴリズムや実装の方法を詳しく説明すると長くなるので、ここではざっくりと解説しておきます。

例えば、下図のような多角形と点の内外判定を考えます。

<img width="400" alt="スクリーンショット 2019-11-28 17.50.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/f339b0b8-4dbc-f493-cb02-9fc94f2c7d20.png">


このとき、点からx軸の正方向への無限な半直線を考えて、多角形と何回交差するかを求めます。
そして、交差回数が奇数なら点は多角形の内部、偶数なら外部と判定されます。

今回の例の場合では、下図のように4回交差します。
交差回数が偶数のため、点は多角形の **外部** に存在すると判定されます。

<img width="400" alt="スクリーンショット 2019-11-28 17.53.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/a92d4db5-bf33-2aec-dd02-1a38bb1c1dd6.png">

逆に、点が下図のような場所に位置する場合、交差回数が奇数になり、多角形の **内部** に存在すると判定されます。

<img width="430" alt="スクリーンショット 2019-11-28 17.53.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/1c31d87c-eb4b-19be-b766-38783104ede2.png">

これを簡潔に実装したのが先ほどのコードとなります。
1つだけ補足をしておくと、外積が正ということはベクトルが反時計回りの順番になっているということを意味します。
この条件は注目している点の左側に位置する辺を除外するために用いられています。

# 今回作成したコンポーネントの全貌

今回作成したコンポーネントのコード全体は次のようになります。
各所で何をやっているかはこれまでの説明で分かるかと思います。

```csharp
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// SpriteのPhysicsShapeの内部のみ当たり判定を有効にするRaycastFilter
/// </summary>
[RequireComponent(typeof(Image))]
public class PhysicsShapeRaycastFilter : MonoBehaviour, ICanvasRaycastFilter
{
    private readonly List<Vector2> _verts = new List<Vector2>();

    private Image _image;

    public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
    {
        var image = _image;
        if (_image == null)
        {
            _image = GetComponent<Image>();
        }

        var rectTransform = transform as RectTransform;
        Vector2 local;
        if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local))
        {
            return false;
        }

        var rect = rectTransform.rect;

        // スプライト内の座標空間での位置を計算する
        var pivot = rectTransform.pivot;
        var sprite = image.sprite;
        var x = (local.x / rect.width + pivot.x - 0.5f) * sprite.rect.width / sprite.pixelsPerUnit;
        var y = (local.y / rect.height + pivot.y - 0.5f) * sprite.rect.height / sprite.pixelsPerUnit;
        var p = new Vector2(x, y);

        var physicsShapeCount = sprite.GetPhysicsShapeCount();
        for (var i = 0; i < physicsShapeCount; i++)
        {
            sprite.GetPhysicsShape(i, _verts);
            if (IsInPolygon(_verts, p))
            {
                // どれかの多角形の内部にあればtrueを返す
                return true;
            }
        }

        return false;
    }

    /// <summary>
    /// 非凸多角形の内部に点が存在するかどうか
    /// </summary>
    private static bool IsInPolygon(List<Vector2> polygon, Vector2 p)
    {
        // pからx軸の正方向への無限な半直線を考えて、多角形との交差回数によって判定する
        var n = polygon.Count;
        var isIn = false;
        for (var i = 0; i < n; i++)
        {
            var nxt = (i + 1);
            if (nxt >= n) nxt = 0;
            var a = polygon[i] - p;
            var b = polygon[nxt] - p;
            if (a.y > b.y)
            {
                // swap
                var t = a;
                a = b;
                b = t;
            }

            if (a.y <= 0 && 0 < b.y && CrossProduct(a, b) > 0)
            {
                isIn = !isIn;
            }
        }

        return isIn;
    }

    /// <summary>
    /// 外積
    /// </summary>
    private static float CrossProduct(Vector2 u, Vector2 v)
    {
        return u.x * v.y - u.y * v.x;
    }
}
```

# 利用方法

<img width="500" alt="スクリーンショット 2019-11-28 18.45.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/ee5272d4-d7ca-8696-3fb4-1941bd9d45bb.png">


図のように、当たり判定をカスタマイズしたいImageコンポーネントがアタッチされたGameObjectにPhysicsShapeRaycastFilterをアタッチして利用します。
Spriteに関しては予めSprite EditorでPhysics Shapeを定義しておく必要があります。


# まとめ

以上、Physics Shapeを使ったuGUIの当たり判定のカスタマイズ方法の紹介でした。
Physics Shapeを使った方法では、当たり判定の形状が画像の見た目に縛られる必要はないため、例えば見た目よりも当たり判定を若干大きくするというようなことも可能です。

思っていたより長くなってしまいましたが、ここまでお読みいただきありがとうございました。
この記事のコード自体はお好きに利用していただいて大丈夫ですが、バグ等の問題が生じても責任は負えませんのでご承知ください。