[Unity] Sprite 無しで枠線付き角丸四角形の図形を uGUI で描画する方法

# 背景
どうも今川です。

ゲーム制作をしていてしばしば、ボタンやフレーム、カーソルといったもので、角丸四角形の図形をuGUIで描画することがあると思います。

これを簡単に実装しようと思った時、Image と 角丸画像 (Sprite) をいくつか用意して作成することが多いと思いますが、枠線をつける際に太さに合わせた画像を作り直したり、拡大して利用しようとするとエイリアシングが気になったりなど問題が発生します。

<img width="100px" alt="スクリーンショット 2023-11-22 18.57.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/bacefb4b-3ef9-ee35-fe28-ac3579c9f721.png">

これらは、角丸画像 (Sprite)から、角丸四角形を描画する事が原因であり、逆に角丸画像 (Sprite)から描画しなければこれらの問題は発生しないわけです。

---

Mask対応に関しては [こちら](https://qiita.com/t_imagawa/items/01ac679842dbb7215af5) に記載しております。あわせてどうぞ。 - 追記 (2023/11/27)

# 利用する技術
- Graphic
- ShaderGraph

## Graphic
Sprite を利用しないため、Imageによる描画は行えません。
そのため、 [Graphic](https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.Graphic.html) を用いて [CanvasRenderer](https://docs.unity3d.com/Packages/com.unity.ugui@2.0/manual/class-CanvasRenderer.html?q=CanvasRenderer) で描画を行います。

## ShaderGraph
Graphic のレンダリングに使用される Material の Shader は独自で作成したものを利用します。
そのため、簡単にShaderを作成するため [ShaderGraph](https://docs.unity3d.com/Packages/com.unity.shadergraph@17.0/manual/First-Shader-Graph.html) を利用しました。

# 前提知識
Spriteを用いらずに図形を描画するために必要な知識として大きく２つあります。

- メッシュの描画
- シェーダーによる図形の描画

## メッシュの描画
独自に uGUI で Canvas にメッシュを描画するには、 [OnPopulateMesh](https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.Graphic.html?q=OnPopulateMesh#UnityEngine_UI_Graphic_OnPopulateMesh_UnityEngine_UI_VertexHelper_) にて　[VertexHelper](https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.VertexHelper.html?q=VertexHelper) にメッシュを指定することで実現できます。

## シェーダーによる図形の描画
メッシュの描画だけでは、ただの四角形が描画されるだけです。角丸の表現や枠線など描画に装飾を行う場合、メッシュにカスタマイズされたシェーダーによるマテリアルを付与して描画する必要があります。
この際、メッシュに描画する際に利用する座標系（UV座標）を指定してあげる必要があるのですが、こちらも [VertexHelper](https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.VertexHelper.html?q=VertexHelper) にメッシュを設定する際に指定することが可能です。

https://gist.github.com/ToshikiImagawa/b93b5a14e75c51385f4cba47bea4ddf7

# 実現方法
それでは Sprite 無しで角丸四角形の図形を uGUI で描画していきます。
角丸の図形を描画するための手順を以下に記載します。

1. 9スライスのメッシュを作成
2. 9スライスのメッシュを角丸の半径に合わせてサイズと座標を調整
3. 9スライスのメッシュに描画したい角丸四角形の図形をShaderを用いて指定

## 9スライスのメッシュを作成
まず角丸四角形の図形を描画する際に必要なメッシュの形状は9スライスのメッシュです。9スライスのメッシュにしない場合、図形全体の収縮に合わせて角丸の形状も変化してしまいます。
よってコーナーのサイズは変化しない9スライスのメッシュ作成する必要があります。

9スライスのメッシュを作成するのに必要な最小の情報は、9分割されたメッシュの対角線上の４つの座標です。

![スクリーンショット 2023-11-22 19.14.30_c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/9ab4ad36-6d6c-90ff-921a-fa4581f23090.png)


これら、の情報を元に9つの四角形のメッシュを作成します。

https://gist.github.com/ToshikiImagawa/a37caa2d227fdb70172d0d2bd96a9bcf

## 9スライスのメッシュを角丸の半径に合わせてサイズと座標を調整
次に角丸の半径に合わせてコーナーのサイズを求めサイズと座標を調整します。

Graphic の RectTransform の Rect (以下 「GraphicRect」 という) に四角形のサイズを合わせるとき、
コーナーのサイズは *角丸の半径*
コーナーの座標は、以下の式で求められます。

- コーナー左下のメッシュの座標
    - 左下 : **GraphicRectの座標**
    - 右上 : **GraphicRectの座標 + コーナーのサイズ**
- コーナー右上のメッシュの座標
    - 左下 : **GraphicRectの座標 + GraphicRectの幅 - コーナーのサイズ**
    - 右上 : **GraphicRectの座標 + GraphicRectの幅**

![スクリーンショット 2023-11-22 19.30.30_c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/91989b99-c38e-0df1-8740-94365962a9ca.png)


この時に枠線を描画することを考慮するのであれば、それらも含めて位置とサイズを調整する必要があります。

枠線について考えてみましょう。
枠線の描画位置は、AdobeやFigmaなどで以下のように定義されていることが多いです。

- Inside : 内側
- Center : 中央
- Outside : 外側

これらは、0~1の値で表現することが可能です。(0: Outside, 0.5: Center, 1: Inside)

また、枠線と角丸の半径、コーナーのサイズと座標の関係性も枠線の描画位置によって変わります。

### コーナーのサイズ
まずは、コーナーのサイズに関して考えてみましょう。
枠線の描画位置によるコーナーのサイズを Outside / Inside に関して考えると以下のようになります。

#### Outside の場合

枠線の太さに関係なく常にコーナーのサイズは **角丸の半径 + 枠線の太さ** になります。

![スクリーンショット 2023-11-22 19.38.26_c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/28a287b5-4829-1da4-c085-622d14a3aa11.png)


#### Inside の場合

枠線の太さと角丸の半径によって、コーナーのサイズが変わります。

- *角丸の半径 > 枠線の太さ* : コーナーのサイズは **角丸の半径**
- *角丸の半径 <= 枠線の太さ* : コーナーのサイズは **枠線の太さ**

*角丸の半径 > 枠線の太さ*
![スクリーンショット 2023-11-22 19.53.22_c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/a17cb711-d37c-d2d6-4c2f-78ab6468f54b.png)

*角丸の半径 <= 枠線の太さ*
![スクリーンショット 2023-11-22 19.53.05_c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/e150c263-bd6c-b38c-0dc1-125fa9f1d4b3.png)

以上のことから、コーナーのサイズは以下の式で求められます。
- *角丸の半径 > 枠線の太さ * 枠線の描画位置* : コーナーのサイズは **角丸の半径 + 枠線の太さ * (1 - 枠線の描画位置)**
- *角丸の半径 <= 枠線の太さ * 枠線の描画位置* : コーナーのサイズは **枠線の太さ**

### コーナーの座標
次に、コーナーの座標に関して考えます。こちらも同じく Outside / Inside に関して考えると以下のようになります。

#### Inside の場合
枠線の太さに関係なくコーナーの位置は、角丸四角形と同じ座標になります。

#### Outside の場合
枠線分、角丸四角形からはみ出るためコーナーの座標は全体的に角丸四角形の中心から外側に向かって **枠線の太さ** 分離れた座標になります。
以上のことから、コーナーの座標は以下の式で求められます。

![スクリーンショット 2023-11-22 19.38.26_p.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/db29394d-5663-10b7-161d-83c4072f41e1.png)


- コーナー左下のメッシュの座標
    - 左下 : (**GraphicRectの座標 - 枠線の太さ * (1 - 枠線の描画位置)**
    - 右上 : **GraphicRectの座標 + コーナーのサイズ - 枠線の太さ * (1 - 枠線の描画位置)**
- コーナー右上のメッシュの座標
    - 左下 : **GraphicRectの座標 + GraphicRectの幅 - コーナーのサイズ + 枠線の太さ * (1 - 枠線の描画位置)**
    - 右上 : **GraphicRectの座標 + GraphicRectの幅 + 枠線の太さ * (1 - 枠線の描画位置)**

以上で、コーナーのサイズと座標が求められました。
これで、角丸の半径と枠線に合ったメッシュを生成する事ができます。

``` cs
protected override void OnPopulateMesh(VertexHelper toFill)
{
    var boarder = radius > thickness * linePosition ? radius + thickness * (1 - linePosition) : thickness;
    var rect = GetPixelAdjustedRect();
    // 9Slice 左下の四角形の左下の座標
    VertScratch[0] = new Vector2(
        rect.x - thickness * (1 - linePosition),
        rect.y - thickness * (1 - linePosition)
    );
    // 9Slice 左下の四角形の右上の座標
    VertScratch[1] = new Vector2(
        boarder + rect.x - thickness * (1 - linePosition),
        boarder + rect.y - thickness * (1 - linePosition)
    );
    // 9Slice 右上の四角形の左下の座標
    VertScratch[2] = new Vector2(
        rect.width - boarder + rect.x + thickness * (1 - linePosition),
        rect.height - boarder + rect.y + thickness * (1 - linePosition)
    );
    // 9Slice 右上の四角形の右上の座標
    VertScratch[3] = new Vector2(
        rect.width + rect.x + thickness * (1 - linePosition),
        rect.height + rect.y + thickness * (1 - linePosition)
    );
    // UV座標左下
    UVScratch[0] = new Vector2(0, 0);
    // UV座標左下から上右に1/4
    UVScratch[1] = new Vector2(0.25f, 0.25f);
    // UV座標右上から下左に1/4
    UVScratch[2] = new Vector2(0.75f, 0.75f);
    // UV座標右上
    UVScratch[3] = new Vector2(1, 1);

    toFill.Clear();
    for (var x = 0; x < 3; ++x)
    {
        var x2 = x + 1;

        for (var y = 0; y < 3; ++y)
        {
            var y2 = y + 1;

            var posMin = new Vector2(VertScratch[x].x, VertScratch[y].y);
            var posMax = new Vector2(VertScratch[x2].x, VertScratch[y2].y);

            AddQuad(
                toFill,
                posMin,
                posMax,
                Color.white,
                new Vector2(UVScratch[x].x, UVScratch[y].y),
                new Vector2(UVScratch[x2].x, UVScratch[y2].y));
        }
    }
}
```

## 9スライスのメッシュに描画したい角丸四角形の図形をShaderを用いて指定

これで、メッシュの準備は整いました。最後に9スライスのメッシュに角丸の図形を描画していきましょう。
まずは、UVについておさらいしましょう。
UV座標とは簡単に言うと、メッシュの面ごとに貼り付けるテクスチャ上の座標を0~1で表したものです(0 : 左下, 1 : 右上)。

メッシュの座標が9スライスだから、UVも上下左右に3等分で考えたいのですが、何かと計算に都合が悪いので２の累乗である４等分にして考えてみます。

UV上では ４x４ の１６スライスの画像をメッシュの9スライスにそれぞれUV座標を当てはめると以下のように考える事ができます。

- コーナー左下のメッシュのUV座標
    - 左下 : (0, 0)
    - 右上 : (0.25, 0.25)
- コーナー右上のメッシュ
    - 左下 : (0.75, 0.75)
    - 右上 : (1, 1)

これで、メッシュ座標とメッシュのUV座標の対応が完了しました。

次にメッシュに描画するテクスチャーの見た目をシェーダーで調整していきます。

### 枠線無しのテクスチャ
まずは枠線がない場合を考えます。

角丸を描画するには、コーナーが角丸それ以外は塗りつぶされているテクスチャを用意すればいいわけです。角丸のサイズは9スライスのメッシュがよしなに調整してくれるため考慮する必要がありません。

角丸の四角形を ShaderGraph で作成する方法は *Rounded Rectangle* を利用すれば作成可能です。

テクスチャのサイズはUVで考えるため、コーナーのサイズは全体の *1/4* のため

- Radius : *0.25*
- Width / Height : *1*

になるわけです。

![スクリーンショット 2023-11-22 10.52.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/c43c627a-6965-9098-5940-09d641c2babf.png)

これで、枠線なしの角丸の描画が完了しました。

![スクリーンショット 2023-11-22 11.07.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/8a53420f-8968-3b19-00cd-09e0400e1310.png)

### 枠線ありのテクスチャ
次に、枠線を含めた描画を考えていきます。
こちらも、メッシュの座標とサイズと同様に枠線と角丸の半径、枠線の位置によってテクスチャが変わります。

まず、角丸四角形と枠線のテクスチャを Outside / Inside に関して考えると以下のようになります。

#### Outside の場合
角丸四角形のテクスチャは、コーナーのサイズが **角丸の半径 + 枠線の太さ** のことから

- Radius : **角丸の半径** / (**角丸の半径** + **枠線の太さ**) * *0.25*
- Width / Height : **角丸の半径** / (**角丸の半径** + **枠線の太さ**) * *0.5* + *0.5*

枠線のテクスチャは、枠線がない時のテクスチャから角丸四角形のテクスチャを引いたテクスチャになります。

![スクリーンショット 2023-11-22 12.34.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/faf0d510-6a1b-4cd6-fd00-3b8d6d2be386.png)

枠線のマテリアルプロパティは以下になります。

- Thickness (Float) : 枠線の太さ
- Radius (Float) : 角丸の半径
- LinePosition (Float) : 枠線の位置 (0: Outside, 0.5: Center, 1: Inside)

枠線の色を変えてみましょう。
マテリアルプロパティに以下を追加します。

- StrokeColor (Color) : 枠線の色
- FillColor (Color) : 角丸四角形の色

枠線と角丸四角形それぞれのアルファ値を各色に合成します。

ここで注意が必要なのですが、加算や乗算による合成では、期待している結果になりません。
いわゆる画像の上に半透明の画像を重ねたような合成は、画像A（上に重ねる画像） 画像B（下の画像） の時、以下の計算で求めます。

- 合成後の透明度 : Out.a = A.a + B.a * (1 - A.a);
- 合成後のRBG : Out.rgb = (A.xyz * A.a + B.rgb * B.a * (1-A.a)) / Out.a;

![スクリーンショット 2023-11-22 13.10.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/9483d3e3-66fb-66c5-caa1-1bc1393be3b2.png)

![スクリーンショット 2023-11-22 18.42.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/3fff7188-a207-ab5e-42e3-50a0b8088f13.png)

こちらの Node は繰り返し使うので SubGraph として登録するのがいいでしょう。

![スクリーンショット 2023-11-22 18.41.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/2acd7efa-63f8-0411-f623-2bb297c2836b.png)

ただ、これではまだ未完成です。
マテリアルプロパティとメッシュの座標とサイズを求めるときに利用した

- 枠線の太さ
- 角丸の半径
- 枠線の位置

の値が同期していません。

マテリアルプロパティの更新には、 [IMaterialModifier.GetModifiedMaterial](https://docs.unity3d.com/Packages/com.unity.ugui@2.0/api/UnityEngine.UI.IMaterialModifier.html?q=IMaterialModifier#methods) を利用します。

GetModifiedMaterial で受け取ったマテリアルをコピーしたマテリアルを生成し、変更を加えたい値のみ値のみ更新していきます。

``` cs
private Material _material;

Material IMaterialModifier.GetModifiedMaterial(Material baseMaterial)
{
    if (_material == null)
    {
        _material = new Material(baseMaterial)
        {
            hideFlags = HideFlags.DontSave
        };
        _material.CopyMatchingPropertiesFromMaterial(baseMaterial);
    }

    _material.SetThickness(thickness);
    _material.SetRadius(radius);
    _material.SetLinePosition(linePosition);
    return _material;
}

protected override void OnDestroy()
{
    base.OnDestroy();
    var m = _material;
    if (m == null) return;
    _material = null;
    if (Application.isPlaying)
    {
        Destroy(m);
    }
    else
    {
        DestroyImmediate(m);
    }
}

---

internal static class MaterialExtensions
{
    private static readonly int Thickness = Shader.PropertyToID($"_{nameof(Thickness)}");
    private static readonly int Radius = Shader.PropertyToID($"_{nameof(Radius)}");
    private static readonly int LinePosition = Shader.PropertyToID($"_{nameof(LinePosition)}");

    public static void SetThickness(this Material material, float value)
    {
        material.SetFloat(Thickness, value);
    }

    public static void SetRadius(this Material material, float value)
    {
        material.SetFloat(Radius, value);
    }

    public static void SetLinePosition(this Material material, float value)
    {
        material.SetFloat(LinePosition, value);
    }
}
```

これで Outside の枠線の描画は完成です。

#### Inside の場合

角丸四角形のテクスチャは、コーナのサイズが以下であることから

- *角丸の半径 > 枠線の太さ* : コーナーのサイズは **角丸の半径**
- *角丸の半径 <= 枠線の太さ* : コーナーのサイズは **枠線の太さ**

*角丸の半径 > 枠線の太さ*　の時、枠線がない時と同じテクスチャになります。

- Radius : *0.25*
- Width / Height : *1*

*角丸の半径 <= 枠線の太さ* の時、 **角丸の半径 / 枠線の太さ** 分コーナーが小さくなります。

- Radius : **角丸の半径** / **枠線の太さ** * *0.25*
- Width / Height : *1*

枠線のテクスチャは、角丸四角形のテクスチャから枠線分内側に小さいテクスチャを引いたテクスチャになります。

*角丸の半径 > 枠線の太さ* の時、枠線分内側に小さいテクスチャは

- Radius : (**角丸の半径** - **枠線の太さ**) / **角丸の半径** * *0.25*
- Width / Height : (**角丸の半径** - **枠線の太さ**) / **角丸の半径** * *0.5* + *0.5*

*角丸の半径 <= 枠線の太さ* の時、 枠線分内側に小さいテクスチャは中央いっぱいに塗りつぶされているため、角丸がない中央の四角形になります。

- Radius : *0*
- Width / Height : *0.5*

![スクリーンショット 2023-11-22 15.18.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/df0c362f-15a6-113b-927b-cc6ecb068fd8.png)

これを Outside と同様に枠の色をつけることで完成します。

*角丸の半径 > 枠線の太さ*
![スクリーンショット 2023-11-22 15.20.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/813918a3-16ca-06b5-2faf-55bce868d3b3.png)

*角丸の半径 <= 枠線の太さ*
![スクリーンショット 2023-11-22 15.20.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/78fa75e6-e3b3-ed62-1b63-9b2ec62bbc72.png)

以上のことから、テクスチャは以下の式で求められます。

*角丸の半径 > 枠線の太さ * 枠線の位置* の時、
枠線のテクスチャは

- Radius : *0.25*
- Width / Height : *1*

の角丸四角形から

- Radius : (**角丸の半径** - **枠線の太さ** * **枠線の位置**) / (**角丸の半径** + **枠線の太さ** * (*1* - **枠線の位置**)) * *0.25*
- Width / Height : (**角丸の半径** - **枠線の太さ** * **枠線の位置**) / (**角丸の半径** + **枠線の太さ** * (*1* - **枠線の位置**)) * *0.5* + *0.5*

の角丸四角形を引いたものになります

*角丸の半径 <= 枠線の太さ * 枠線の位置* の時、

- Radius : (**角丸の半径** + **枠線の太さ** * (*1* - **枠線の位置**)) / **枠線の太さ** * 0.25
- Width / Height : *1*

の角丸四角形から

- Radius : *0*
- Width / Height : *0.5*

の正方形を引いたものになります

角丸四角形のテクスチャは、境界線を求める時に利用した外側の四角形Aと内側の四角形Bの間であるため

- Radius : Lerp(B.Radius, A.Radius, **枠線の位置**)
- Width / Height : Lerp(B.(Width / Height), A.(Width / Height), **枠線の位置**)

で求めることができます。

![スクリーンショット 2023-11-22 16.55.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121445/91617501-679e-589e-6508-c053a043de9f.png)

# 結論
以下のコンポーネントと Shader を利用することで、Sprite 無しで枠つきの角丸四角形の図形を描画する事ができます。

## Code
https://gist.github.com/ToshikiImagawa/543250619282b36a45821ea76c0f919a

## Shader
https://gist.github.com/ToshikiImagawa/e3392888139d5cc95d6bb667b508bfef

あとは、拡張として枠線を光らせたり、中の塗りを消したり、擬似的にカラーを重ねたりなど、ShaderGraphなので簡単に機能拡張が可能です。