【Unity】BaseMeshEffectを継承したクラスを使った角丸表示

このエントリは【カヤック】面白法人グループ Advent Calendar 2024の19日目の記事です。

はじめに

こんにちは、カヤックアキバスタジオでエンジニアをやっている臼井です。
今年も残りわずかですね、みなさんいかがお過ごしでしょうか?
さて、今回はuGUIのImage、RawImageの角丸表示をやってみた内容を紹介していこうと思います。

環境

  • MacBook Pro 14インチ(macOS Sonoma 14.1)
  • Unity2022.3.52f1

準備

  • Unity HubからNew Projectを選択し空プロジェクトを作成します
  • 使いたい画像をAssetsフォルダ内に配置して、Inspector上でTexture TypeをSprite(2D and UI)に変更しApplyボタンを押します。
    • ※RawImageで使用する場合は、Defaultのままで大丈夫です
  • Hierarchy上で右クリック > UI > Imageを選択します
  • 追加したImageオブジェクトを選択して、Inspector上からSource Imageに追加した画像をセットします
  • Assetsフォルダを右クリック > Create > C# Scriptを選択し、ファイル名をRoundedMeshEffectとして設定します
  • Hierarchy上のImageオブジェクトを選択してInspector上から追加したRoundedMeshEffectコンポーネントをアタッチします

実装

BaseMeshEffectを継承

RoundedMeshEffect.csを開いて下記内容のように書きます。

using UnityEngine.UI;

public class RoundedMeshEffect : BaseMeshEffect
{
    public override void ModifyMesh(VertexHelper vh)
    {
    }
}

Imageコンポーネント側でメッシュ情報が設定されているので関数内が空でも今まで通り表示されます。
関数内でvh.Clearを呼び出すとメッシュ情報がなくなるため、何も表示されなくなります。
現在の情報を取得するにはvh.GetUIVertexStreamを使用するとこで取得ができるので
新しく追加したい情報だけを設定していけばいいのですが、
今回は0ベースで設定しようと思うのでvh.Clearを呼び出して処理を書いていこうと思います。

表示を9分割に分けて書いて行きます。

真ん中を表示

まずは真ん中を表示するための処理を作成します。
Imageの場合は、設定したspriteがAtlas化されている場合を考慮して、
uvRectの値を算出しています。

using UnityEngine;
using UnityEngine.UI;

public class RoundedMeshEffect : BaseMeshEffect
{
    public override void ModifyMesh(VertexHelper vh)
    {
        // 情報をクリアする
        vh.Clear();
        
        var radius = 100f; // 角丸の半径
        var division = 8; // 角丸の分割数

        var rectTransform = (RectTransform)transform;
        var size = new Vector2(rectTransform.rect.width, rectTransform.rect.height);
        var halfSize = size / 2f;
        var color32 = new Color32(255, 0, 0, 255);
        
        // 設定する座標からuv値を取得するために
        // 表示する頂点の最小、最大値を取得しておく
        var min = new Vector2(-halfSize.x, -halfSize.y);
        var max = new Vector2(halfSize.x, halfSize.y);
        
        // 角丸分だけサイズを縮める
        var r2 = radius * 2f;
        size.x -= r2;
        size.y -= r2;
        halfSize = size / 2f;
        
        // Pivot考慮するためにオフセット値を取得
        var offsetPos = rectTransform.rect.size * (new Vector2(0.5f, 0.5f) - rectTransform.pivot);
        
        // UV情報を取得
        var sprite = ((Image)graphic).sprite;
        var uvRect = new Rect(0, 0, 1, 1);
        if (sprite != null && sprite.texture != null)
        {
            var textureRect = sprite.textureRect;
            uvRect = new Rect(
                textureRect.x / sprite.texture.width,
                textureRect.y / sprite.texture.height,
                (textureRect.x + textureRect.width) / sprite.texture.width,
                (textureRect.y + textureRect.height) / sprite.texture.height);
        }
        
        // 左上
        var pos = new Vector2(-halfSize.x, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));
        
        // 右上
        pos = new Vector2(halfSize.x, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        // 右下
        pos = new Vector2(halfSize.x, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        // 左下
        pos = new Vector3(-halfSize.x, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        // 追加した頂点の繋ぎ方を設定
        vh.AddTriangle(0, 1, 2);
        vh.AddTriangle(2, 3, 0);
    }
}

上下左右に四角表示を追加

先ほどの最後の処理に下記内容を追加します。

        // 上
        color32 = new Color32(255, 0, 0, 255);
        pos = new Vector2(-halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(4, 5, 1);
        vh.AddTriangle(1, 0, 4);

赤くなっている箇所が新しく情報を追加して表示したものとなります。

同じ要領で下と左右を追加したものがこちらです。

上下左右追加処理

        // 上
        color32 = new Color32(255, 0, 0, 255);
        pos = new Vector2(-halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(4, 5, 1);
        vh.AddTriangle(1, 0, 4);
        
        // 下
        pos = new Vector2(-halfSize.x, -halfSize.y - radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x, -halfSize.y - radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(3, 2, 7);
        vh.AddTriangle(7, 6, 3);
        
        // 左
        pos = new Vector2(-halfSize.x - radius, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(-halfSize.x - radius, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));
        
        vh.AddTriangle(8, 0, 3);
        vh.AddTriangle(3, 9, 8);

        // 右
        pos = new Vector2(halfSize.x + radius, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x + radius, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(1, 10, 11);
        vh.AddTriangle(11, 2, 1);

角丸表示を追加

角丸は90度(Mathf.PI / 2f)を分割数で割った数分、頂点を追加し行って繋ぐことで表示をしています。
分割数が多いほど頂点数が多くなるため綺麗な角丸を表現することができます。
下記処理で左上に角丸を表示します。

        // 角丸で使用する情報を用意
        const float HALF_PI = Mathf.PI / 2f;
        var triangleNum = division + 1;
        var addRad = HALF_PI / triangleNum;
        var offsetRad = Mathf.PI + HALF_PI;
        var rad = -offsetRad + HALF_PI;
        var vertCount = vh.currentVertCount;
        color32 = new Color32(255, 0, 0, 255);
        
        // 左上に角丸表示
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(-halfSize.x + Mathf.Cos(rad) * radius,
                halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        var centerVertIndex = 0;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }

上下左右それぞれに角丸を表示したものがこちら

上下左右追加処理

        // 角丸で使用する情報を用意
        const float HALF_PI = Mathf.PI / 2f;
        var triangleNum = division + 1;
        var addRad = HALF_PI / triangleNum;
        var offsetRad = Mathf.PI + HALF_PI;
        var rad = -offsetRad + HALF_PI;
        var vertCount = vh.currentVertCount;
        color32 = new Color32(255, 0, 0, 255);
        
        // 左上に角丸表示
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(-halfSize.x + Mathf.Cos(rad) * radius,
                halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        var centerVertIndex = 0;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
        
        // 右上に角丸表示
        vertCount = vh.currentVertCount;
        offsetRad = 0;
        rad = -offsetRad + HALF_PI;
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(halfSize.x + Mathf.Cos(rad) * radius,
                halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        centerVertIndex = 1;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
        
        // 右下に角丸表示
        vertCount = vh.currentVertCount;
        offsetRad = HALF_PI;
        rad = -offsetRad + HALF_PI;
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(halfSize.x + Mathf.Cos(rad) * radius,
                -halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        centerVertIndex = 2;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
        
        // 左下に角丸表示
        vertCount = vh.currentVertCount;
        offsetRad = Mathf.PI;
        rad = -offsetRad + HALF_PI;
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(-halfSize.x + Mathf.Cos(rad) * radius,
                -halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        centerVertIndex = 3;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }

完成

半径、分割数頭をInspectorから設定できるようにしつつ、処理を整理したものがこちらになります。

RoundedMeshEffect.cs

public class RoundedMeshEffect : BaseMeshEffect
{
    const float HALF_PI = Mathf.PI / 2f;
    static readonly Rect _defaultUVRect = new Rect(0, 0, 1, 1);

    [SerializeField] float _radius = 20f;
    [SerializeField, Range(1, 12)] int _division = 8;
    [SerializeField] bool _isFitSize;
    [SerializeField] Vector2 _size = new(100, 100);
    [NonSerialized] RectTransform? _cacheRectTransform;

    Vector3 _min;
    Vector3 _max;
    Vector3 _offsetPos;
    Rect _uvRect;
    Color _color32;

    public RectTransform rectTransform => _cacheRectTransform ??= (RectTransform)transform;

    public override void ModifyMesh(VertexHelper vh)
    {
        if (_radius < 0f)
        {
            _radius = 0f;
        }

        vh.Clear();

        var size = new Vector2(rectTransform.rect.width, rectTransform.rect.height);
        var halfSize = size / 2f;

        _min.x = -halfSize.x;
        _min.y = -halfSize.y;
        _max.x = halfSize.x;
        _max.y = halfSize.y;

        if (!_isFitSize)
        {
            size = _size;
        }

        var r2 = _radius * 2f;
        size.x -= r2;
        size.y -= r2;
        halfSize = size / 2f;

        _color32 = graphic.color;
        _offsetPos = rectTransform.rect.size * (new Vector2(0.5f, 0.5f) - rectTransform.pivot);
        _uvRect = GetUVRect();

        // 中心
        var topLeft = new Vector3(-halfSize.x, halfSize.y);
        AddVert(vh, topLeft); // 0
        var topRight = new Vector3(halfSize.x, halfSize.y);
        AddVert(vh, topRight); // 1
        var bottomRight = new Vector3(halfSize.x, -halfSize.y);
        AddVert(vh, bottomRight); // 2
        var bottomLeft = new Vector3(-halfSize.x, -halfSize.y);
        AddVert(vh, bottomLeft); // 3

        vh.AddTriangle(0, 1, 2);
        vh.AddTriangle(2, 3, 0);

        // 上
        AddVert(vh, new Vector3(-halfSize.x, halfSize.y + _radius)); // 4
        AddVert(vh, new Vector3(halfSize.x, halfSize.y + _radius)); // 5

        vh.AddTriangle(4, 5, 1);
        vh.AddTriangle(1, 0, 4);

        // 下
        AddVert(vh, new Vector3(-halfSize.x, -halfSize.y - _radius)); // 6
        AddVert(vh, new Vector3(halfSize.x, -halfSize.y - _radius)); // 7

        vh.AddTriangle(3, 2, 7);
        vh.AddTriangle(7, 6, 3);

        // 左
        AddVert(vh, new Vector3(-halfSize.x - _radius, halfSize.y)); // 8
        AddVert(vh, new Vector3(-halfSize.x - _radius, -halfSize.y)); // 9

        vh.AddTriangle(8, 0, 3);
        vh.AddTriangle(3, 9, 8);

        // 右
        AddVert(vh, new Vector3(halfSize.x + _radius, halfSize.y)); // 10
        AddVert(vh, new Vector3(halfSize.x + _radius, -halfSize.y)); // 11

        vh.AddTriangle(1, 10, 11);
        vh.AddTriangle(11, 2, 1);

        // 角
        AddHorns(vh, 0, topLeft, Mathf.PI + HALF_PI, _radius);
        AddHorns(vh, 1, topRight, 0f, _radius);
        AddHorns(vh, 2, bottomRight, HALF_PI, _radius);
        AddHorns(vh, 3, bottomLeft, Mathf.PI, _radius);
    }

    void AddVert(VertexHelper vh, in Vector3 pos)
    {
        vh.AddVert(pos + _offsetPos, _color32,
            new Vector2(
                Mathf.Lerp(_uvRect.xMin, _uvRect.xMax, Mathf.InverseLerp(_min.x, _max.x, pos.x)),
                Mathf.Lerp(_uvRect.yMin, _uvRect.yMax, Mathf.InverseLerp(_min.y, _max.y, pos.y))));
    }

    void AddHorns(VertexHelper vh, int centerVertIndex, in Vector3 centerPos, float offsetRad, float radius)
    {
        var triangleNum = _division + 1;
        var addRad = HALF_PI / triangleNum;
        var rad = -offsetRad + HALF_PI;
        var vertCount = vh.currentVertCount;
        for (var i = 0; i < (_division + 2); ++i)
        {
            AddVert(vh, new Vector3(centerPos.x + Mathf.Cos(rad) * radius,
                centerPos.y + Mathf.Sin(rad) * radius));

            rad -= addRad;
        }

        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
    }

    Rect GetUVRect()
    {
        return graphic switch
        {
            Image image => GetUVRectForImage(image),
            RawImage rawImage => GetUVRectForRawImage(rawImage),
            _ => _defaultUVRect
        };
    }

    static Rect GetUVRectForImage(Image g)
    {
        if (g.sprite == null || g.sprite.texture == null)
        {
            return _defaultUVRect;
        }

        var sprite = g.sprite;
        var textureRect = sprite.textureRect;
        return new Rect(
            textureRect.x / sprite.texture.width,
            textureRect.y / sprite.texture.height,
            (textureRect.x + textureRect.width) / sprite.texture.width,
            (textureRect.y + textureRect.height) / sprite.texture.height);
    }

    static Rect GetUVRectForRawImage(RawImage g)
    {
        var scaleX = g.mainTexture.width * g.mainTexture.texelSize.x;
        var scaleY = g.mainTexture.height * g.mainTexture.texelSize.y;
        var r = g.uvRect;
        r.xMin *= scaleX;
        r.xMax *= scaleX;
        r.yMin *= scaleY;
        r.yMax *= scaleY;
        return r;
    }
}

まとめ

今回は頂点を追加する方法でやってみましたがいかがでしたでしょうか?
他にもImage、RawImageを継承してOnPopulateMesh関数内で処理するやり方や
shaderで対応する方法もあるので機会があれば作ってみたいなと思います。
また負荷検証は特にしなかったので機会があればやってみたいですね。