UnityのProBuilderのMeshをSpriteShapeのSplineに変換

# 概要

https://qiita.com/dieblondebestie/items/f92ad2e4b56be4c06bb9

2DベルトアクションゲームのキャラクターコントローラーにTinyCharacterController（TCC）を利用してるんですが、TCCは基底クラスが3DColliderです。2Dな表現をしたいのでSpriteShapeを使おうと思いましたが、こちらで生成されるコライダーは2DColliderです。
ProBuilderで作る3Dは一緒に3DColliderも作られるため、当たり判定はProBuilder、見た目をSpriteShapeで使用することにしましたが、それぞれ手動で設定するのは手間なので変換ツールを作成しました。

:::note warn
UnityはColliderの当たり判定が、2Dと2D、もしくは3Dと3Dでないと判定されません。
:::

# 実装
Splineは簡単に言うと頂点の位置や曲線を司るアンカーポイントの位置を格納したSplineControlPointのListで構成されていて、その中身で形が決まります。基本的に頂点の順番が輪のように格納されています。
しかしMeshは頂点の位置を配列で格納されているのは一緒ですが、Meshの三角形を構成する頂点の情報をもとに形が作られるため、単純にMeshの頂点をそのまま持ってくればいいわけではありません。
無作為な複数の頂点から輪になる順番を探す方法もありましたが、形が複雑になると精度が低くなってダメでした。
そこで、Meshの2分割してその断面のMeshを生成する方法を利用すれば簡単にSpline用の頂点の配列を取得できるようになりました。
以下の記事で分割の詳しい方法を載せていらっしゃった方がいました。ありがとうございます。

https://qiita.com/edo_m18/items/31961cd19fd19e09b675

詳しい原理は記事を読んで頂ければ分かりますが、分割することで輪になるような順番で頂点を取得できます。

<details><summary>MeshCutのコードから頂点の配列を取得するとこだけにしたもの</summary>

```C#
public static Vector2[] GeneratePoints(Mesh originalMesh)
{
    var vertices = new List<Vector3>();
    var plane = new Plane(Vector3.forward, Vector3.zero);
    var indices = originalMesh.triangles;
    var sides = new bool[3];
    for (int i = 0; i < indices.Length; i += 3)
    {
        var p1 = indices[i + 0];
        var p2 = indices[i + 1];
        var p3 = indices[i + 2];

        sides[0] = plane.GetSide(originalMesh.vertices[p1]);
        sides[1] = plane.GetSide(originalMesh.vertices[p2]);
        sides[2] = plane.GetSide(originalMesh.vertices[p3]);

        if (!(sides[0] == sides[1] && sides[0] == sides[2]))
        {
            var data = CutFace(originalMesh, plane, sides, p1, p2, p3);
            vertices.AddRange(data);
        }
    }

    var capVertTracker = new List<Vector3>();
    var capVertpolygon = new List<Vector3>();

    // カット用頂点追跡リスト
    // 具体的には新頂点全部に対する調査を行う。その過程で調査済みをマークする目的で利用する。
    capVertTracker.Clear();

    // 新しく生成した頂点分だけループする＝全新頂点に対してポリゴンを形成するため調査を行う
    // 具体的には、カット面を構成するポリゴンを形成するため、カット時に重複した頂点を網羅して「面」を形成する頂点を調査する
    for (int i = 0; i < vertices.Count; i++)
    {
        // 対象頂点がすでに調査済みのマークされて（追跡配列に含まれて）いたらスキップ
        if (capVertTracker.Contains(vertices[i]))
        {
            continue;
        }

        // カット用ポリゴン配列をクリア
        capVertpolygon.Clear();

        // 調査頂点と次の頂点をポリゴン配列に保持する
        capVertpolygon.Add(vertices[i + 0]);
        capVertpolygon.Add(vertices[i + 1]);

        // 追跡配列に自身と次の頂点を追加する（調査済みのマークをつける）
        capVertTracker.Add(vertices[i + 0]);
        capVertTracker.Add(vertices[i + 1]);

        // 重複頂点がなくなるまでループし調査する
        bool isDone = false;
        while (!isDone)
        {
            isDone = true;

            // 新頂点郡をループし、「面」を構成する要因となる頂点をすべて抽出する。抽出が終わるまでループを繰り返す
            // 2頂点ごとに調査を行うため、ループは2単位ですすめる
            for (int k = 0; k < vertices.Count; k += 2)
            { 
                // go through the pairs
                // ペアとなる頂点を探す
                // ここでのペアとは、いちトライアングルから生成される新頂点のペア。
                // トライアングルからは必ず2頂点が生成されるため、それを探す。
                // また、全ポリゴンに対して分割点を生成しているため、ほぼ必ず、まったく同じ位置に存在する、別トライアングルの分割頂点が存在するはずである。
                if (vertices[k] == capVertpolygon[capVertpolygon.Count - 1] && !capVertTracker.Contains(vertices[k + 1]))
                {   // if so add the other
                    // ペアの頂点が見つかったらそれをポリゴン配列に追加し、
                    // 調査済マークをつけて、次のループ処理に回す
                    isDone = false;
                    capVertpolygon.Add(vertices[k + 1]);
                    capVertTracker.Add(vertices[k + 1]);
                }
                else if (vertices[k + 1] == capVertpolygon[capVertpolygon.Count - 1] && !capVertTracker.Contains(vertices[k]))
                {   // if so add the other
                    isDone = false;
                    capVertpolygon.Add(vertices[k]);
                    capVertTracker.Add(vertices[k]);
                }
            }
        }
    }
    return capVertpolygon.Select(v => new Vector2(v.x, v.y)).ToArray();
}

private static List<Vector3> CutFace(Mesh mesh, Plane plane, bool[] sides, int index1, int index2, int index3)
{
    var vertices = new List<Vector3>();

    // 左右それぞれの情報を保持するための配列郡
    Vector3[] leftPoints = new Vector3[2];
    Vector3[] rightPoints = new Vector3[2];

    bool didset_left = false;
    bool didset_right = false;

    // 3頂点分繰り返す
    // 処理内容としては、左右を判定して、左右の配列に3頂点を振り分ける処理を行っている
    int p = index1;
    for (int side = 0; side < 3; side++)
    {
        switch(side)
        {
            case 0:
                p = index1;
                break;
            case 1:
                p = index2;
                break;
            case 2:
                p = index3;
                break;
        }

        // sides[side]がtrue、つまり左側の場合
        if (sides[side])
        {
            // すでに左側の頂点が設定されているか（3頂点が左右に振り分けられるため、必ず左右どちらかは2つの頂点を持つことになる）
            if (!didset_left)
            {
                didset_left = true;

                // ここは0,1ともに同じ値にしているのは、続く処理で
                // leftPoints[0,1]の値を使って分割点を求める処理をしているため。
                // つまり、アクセスされる可能性がある

                // 頂点の設定
                leftPoints[0]  = mesh.vertices[p];
                leftPoints[1]  = leftPoints[0];
            }
            else
            {
                // 2頂点目の場合は2番目に直接頂点情報を設定する
                leftPoints[1]  = mesh.vertices[p];
            }
        }
        else
        {
            // 左と同様の操作を右にも行う
            if (!didset_right)
            {
                didset_right = true;

                rightPoints[0]  = mesh.vertices[p];
                rightPoints[1]  = rightPoints[0];
            }
            else
            {
                rightPoints[1]  = mesh.vertices[p];
            }
        }
    }

    // 分割された点の比率計算のための距離
    float normalizedDistance = 0f;
    
    // 距離
    float distance = 0f;


    // ---------------------------
    // 左側の処理

    // 定義した面と交差する点を探す。
    // つまり、平面によって分割される点を探す。
    // 左の点を起点に、右の点に向けたレイを飛ばし、その分割点を探る。
    plane.Raycast(new Ray(leftPoints[0], (rightPoints[0] - leftPoints[0]).normalized), out distance);

    // 見つかった交差点を、頂点間の距離で割ることで、分割点の左右の割合を算出する
    normalizedDistance = distance / (rightPoints[0] - leftPoints[0]).magnitude;

    // カット後の新頂点に対する処理。フラグメントシェーダでの補完と同じく、分割した位置に応じて適切に補完した値を設定する
    Vector3 newVertex1 = Vector3.Lerp(leftPoints[0], rightPoints[0], normalizedDistance);

    // 新頂点郡に新しい頂点を追加
    vertices.Add(newVertex1);


    // ---------------------------
    // 右側の処理

    plane.Raycast(new Ray(leftPoints[1], (rightPoints[1] - leftPoints[1]).normalized), out distance);

    normalizedDistance = distance / (rightPoints[1] - leftPoints[1]).magnitude;
    Vector3 newVertex2 = Vector3.Lerp(leftPoints[1], rightPoints[1], normalizedDistance);

    // 新頂点郡に新しい頂点を追加
    vertices.Add(newVertex2);

    return vertices;
}
```

</details>

後はこの頂点の配列をもとにSplineを作り、SpriteShapeに渡せばOKです。
Probuilderで作成したMeshを横から見た時の形がそのままSpriteShapeのSplineになります。
Meshの頂点の位置のZが0だと断面の計算エラーでるのでMesh作成するときは気を付けます。

```C#
public static void CreateSpriteShape()
{
    var proBuilders = new List<ProBuilderMesh>();
    var oneWayPlatforms = new List<OneWayPlatform>();
    //選択中のGameObjectの配下全てのうちProBuilderMeshがアタッチされてるものを取得
    foreach (var select in Selection.gameObjects)
    {
        proBuilders.AddRange(select.GetComponentsInChildren<ProBuilderMesh>());
        oneWayPlatforms.AddRange(select.GetComponentsInChildren<OneWayPlatform>());
    }

    var fillMaterial = AssetDatabase.LoadAssetAtPath<Material>(FillMaterialPath);
    var edgeMaterial = AssetDatabase.LoadAssetAtPath<Material>(EdgeMaterialPath);
    var profile = AssetDatabase.LoadAssetAtPath<SpriteShape>(profilePath);
    
    foreach (var proBuilder in proBuilders)
    {
        proBuilder.gameObject.layer = (int)GenericConst.Layer.Terrian;
        //ProBuilderMeshのMeshはアクセス修飾子がPrivateなのでSerializedObject経由で取得
        var serializedObject = new SerializedObject(proBuilder);
        var meshProperty = serializedObject.FindProperty("m_Mesh");
        var mesh = meshProperty.objectReferenceValue as Mesh;
        //Meshから頂点の配列を取得（折りたたみの部分）
        var points = GeneratePoints(mesh);
        GameObject child = default;
        SpriteShapeController spriteShape = default;
        SpriteShapeRenderer shapeRenderer = default;
        EdgeCollider2D collider = default;
        //Probuilderの子にSpriteShapeがあったらSpline更新。なかったら作る。
        if (proBuilder.transform.childCount > 0)
        {
            child = proBuilder.transform.GetChild(0).gameObject;
            if (!child.TryGetComponent(out spriteShape))
            {
                shapeRenderer = child.AddComponent<SpriteShapeRenderer>();
                spriteShape = child.AddComponent<SpriteShapeController>();
                spriteShape.spriteShape = profile;
            }
            if (!child.TryGetComponent(out collider))
                collider = child.AddComponent<EdgeCollider2D>();
        }
        else
        {
            child = new GameObject(ObjectNames);
            child.transform.parent = proBuilder.transform;
            child.transform.localPosition = Vector3.zero;
            child.transform.localScale = Vector3.one;
            child.transform.localRotation = quaternion.identity;
            shapeRenderer = child.AddComponent<SpriteShapeRenderer>();
            spriteShape = child.AddComponent<SpriteShapeController>();
            collider = child.AddComponent<EdgeCollider2D>();
            spriteShape.spriteShape = profile;
        }

        child.layer = (int)GenericConst.Layer.Terrian;

        if (shapeRenderer)
        {
            shapeRenderer.sharedMaterials = new Material[] {fillMaterial, edgeMaterial};
            shapeRenderer.sortingLayerID = SortingLayer.NameToID("Terrain");
        }

        var spline = spriteShape.spline;
        //Splineを初期化
        spline.Clear();
        //配列が輪の向きが逆なので反転
        Array.Reverse(points);

        foreach (var (point, index) in points.Select((point, index) => (point, index)))
        {
            var beforeDir = Vector2.left;
            var afterDir = Vector2.left;

            //一個前と一個後の頂点の位置を取得
            var beforePoint = index > 0 ? points[index - 1] : points[points.Length - 1];
            var afterPoint = index < points.Length - 1 ? points[index + 1] : points[0];

            //頂点からそれぞれ伸びてる線分の角度を求める
            beforeDir = (beforePoint - point).normalized;
            afterDir = (point - afterPoint).normalized;

            //分割から取得した頂点は仕様上直線上にも頂点を作ってしまうので、それを省く。
            var dot = Vector2.Dot(beforeDir, afterDir);
            if (dot < 0.99f)
            {
                var splineIndex = spline.GetPointCount();
                spline.InsertPointAt(splineIndex, point);

                //角度が60度未満だったら曲線にしたいのでアンカーポイント設定します。
                if (dot > 0.5f)
                {
                    var tangentDir = (beforePoint - afterPoint) / 10;
                    spline.SetTangentMode(splineIndex, ShapeTangentMode.Continuous);
                    spline.SetLeftTangent(splineIndex, tangentDir);
                    spline.SetRightTangent(splineIndex, -tangentDir);
                }
            }
        }
    }

    //こっちは輪作らないタイプ
    foreach (var oneWayPlatform in oneWayPlatforms)
    {
        var parentScale = oneWayPlatform.transform.localScale;
        oneWayPlatform.gameObject.layer = 8;
        GameObject child = default;
        SpriteShapeController spriteShape = default;
        SpriteShapeRenderer shapeRenderer = default;
        if (oneWayPlatform.transform.childCount > 0)
        {
            child = oneWayPlatform.transform.GetChild(0).gameObject;
            if (!child.TryGetComponent(out spriteShape))
            {
                shapeRenderer = child.AddComponent<SpriteShapeRenderer>();
                spriteShape = child.AddComponent<SpriteShapeController>();
                spriteShape.spriteShape = profile;
            }
        }
        else
        {
            child = new GameObject(ObjectNames);
            child.transform.parent = oneWayPlatform.transform;
            child.transform.localPosition = Vector3.zero;
            child.transform.localScale = Vector3.one.Divide(parentScale);
            child.transform.localRotation = quaternion.identity;
            shapeRenderer = child.AddComponent<SpriteShapeRenderer>();
            spriteShape = child.AddComponent<SpriteShapeController>();
            spriteShape.spriteShape = profile;
        }

        if (shapeRenderer)
        {
            shapeRenderer.sharedMaterials = new Material[] {fillMaterial, edgeMaterial};
            shapeRenderer.sortingLayerID = SortingLayer.NameToID("Terrain");
        }

        var spline = spriteShape.spline;
        spline.Clear();
        spline.InsertPointAt(spline.GetPointCount(), Vector2.left * parentScale.x / 2);
        spline.InsertPointAt(spline.GetPointCount(), Vector2.right * parentScale.x / 2);
        spline.isOpenEnded = false;
    }
}
```

# まとめ
そもそもColiderは3D、Rendererは2Dを使うみたいなことがそもそも少ないかもです。
ただSpriteShapeよりProBuilderのほうが地形作りやすい気しますので、そういう運用ならありかなとういう感じです。