Unityで直線と円弧を使ってETロボコンっぽいコースを生成する

# はじめに

Unityで動的にライントレース用コースを生成できるよう、下図のようなコースを数値指定で作りたくなったので作りました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/4851adcc-b009-ebbe-e843-bfcb5258b2af.png)

[ETロボコン](https://www.etrobo.jp/)を題材にしていますが、テクスチャを変えれば特に用途を限定しないと思われます。こちらでソースを公開していますので自由にご活用ください。Unity2020.3.15f2で動作確認しています。

https://github.com/YoshitakaAtarashi/RoadTest

# 使い方

SceneにGameObject(Road)を追加し、ここにMeshRendererとMeshFilterと、RoadScriptを追加します。RoadScriptの中の道路セグメント(Segments)に要素を追加し、直線距離(length)、曲線の旋回半径(radius)と旋回角度(theta)、道路幅の半分(width)を設定します。複数の要素を追加することで、1本の道路を表現することができます。

道路セグメントの初期位置は(0,0,0)固定になっていますが、RoadのTransformをいじれば如何様にでもできますので、特に不便はないかと思います。

試しにサンプル https://github.com/YoshitakaAtarashi/RoadTest のRoadのSegmentsの値を色々と変えてみてください。わりと直観的にコースを作れると思います。背景として敷いている緑のシート(CourseBasePlane)のYが0になっていますので、RoadのYを0.01として、少し上げておくことで表示の不具合を回避しています。（描画順序を調整するなど、もっと賢いやり方があるかもしれません）

これがサンプルの初期状態です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/084ad434-4b14-e1d4-8c41-05fb87e31c70.png)

Element0のLengthを5、Thetaを70に変更すると、こんな感じで最初のカーブがゆるくなり、全体のコース形状が変化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/bfe9e7be-21a7-963e-b325-d751bbc6b43d.png)

# 道路セグメント(RoadSegment)の概念について

通常、道路を設計する際には色々な曲線が登場すると思うのですが、ETロボコン的なコースの場合、[直線→円弧]→[直線→円弧]→...を繰り返すことで、かなりそれっぽいコースが作れます。下図はRoadSegmentのパラメータが道路の描画にどう影響するかを示した図です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/b5e73bf0-8b01-ab3d-cfab-661ef9aa057a.png)

Unity上で道路セグメントに沿った描画をさせるためには、三角形で構成されるMeshを作成し、MeshFilterを通してMeshRendererに描画させる必要があります。直線部分は三角形2個、円弧部分は定数DIV_THETAで指定した度数で分割した数×2個の三角形を生成します。本サンプルでは5度としていますので、90度のカーブでは90÷5×2=36個の三角形が生成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/e01ab22e-b83c-c885-d9f2-025fafccaed5.png)

最初の直線の四角形に注目すると、頂点座標は左下が0番目、右下が1番目、左上が2番目、右上が3番目に配置されています。これを2つの三角形に分割する際に、(0,2,1)、(1,2,3)という2つの頂点列を指定することで、左手の法則に従って法線が上に向いた三角形を作ることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/aabe5cec-9c81-9486-aacc-9c7974f027a9.png)

UV列については、テクスチャのTilingの設定がこうなっている場合ですが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/85235a0c-3e4d-3596-f003-9f16995c9080.png)

下図のように設定すれば、道路テクスチャが縦方向に繰り返し貼られます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/143adc92-a612-07de-7eae-2805df4615dc.png)


# 道路セグメント(RoadSegment)の接続

各道路セグメントの直線と円弧は独立に頂点列を計算しておきます。道路セグメント間の接続点は現在の道路セグメントの終点と、次の道路セグメントの始点になります。接続点の座標と向きを覚えておき、次の道路セグメントの頂点列を移動・回転させることで道路セグメントを接続することができます。

Vector3の移動は足し算、回転はQuaternion.AngleAxisで実現できます。回転軸をVector3.upにとり、旋回角度thetaを設定することでクオータニオンを得ることができます。これをVector3に掛けることで回転が実現できます。

``` C#
Quaternion.AngleAxis(theta, Vector3.up);
```


# RoadScript

ここまで説明してきた内容を踏まえて作成したRoadScriptは下記のようなコードになっています。OnValidateは便利ですが、デバッグ時以外は外すほうがよいかもしれません。

``` C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class RoadSegment {

    public float length; // 直線距離 [単位10cm]
    public float radius; // 旋回半径 [単位10cm]
    public float theta;  // 旋回角度 [度、時計回りが正]。0を設定すると曲線が描画されず直線のみとなる。
    public float width = 1.6f; // 道路の幅の半分 [単位10cm] ETロボコンの道路幅32cmを再現する場合は1.6とする。
    static readonly float DIV_THETA = 5.0f; // カーブの分割角度

    // 道路セグメントの頂点列を作成する。
    public List<Vector3> makeVertices()
    {
        var vertices=new List<Vector3>();

        vertices.Add(new Vector3(-width, 0, 0));
        vertices.Add(new Vector3(+width, 0, 0));

        // 直線
        if (length > 0)
        {
            vertices.Add(new Vector3(-width, 0, length));
            vertices.Add(new Vector3(+width, 0, length));
        }

        // 曲線 (theta==0の時は描画しない。)
        if (radius < width)
        {
            // 最小旋回半径: radiusがwidthより小さい場合はwidthに合わせる。
            radius = width;
        }
        if (theta > 0)
        {
            // 時計回り
            for (int i = 0; i < (int)(theta / DIV_THETA); i++)
            {
                float t = (float)(DIV_THETA * i) * Mathf.Deg2Rad;
                var v_out = new Vector3(radius - (radius + width) * Mathf.Cos(t), 0, (radius + width) * Mathf.Sin(t) + length);
                vertices.Add(v_out);
                var v_in = new Vector3(radius - (radius - width) * Mathf.Cos(t), 0, (radius - width) * Mathf.Sin(t) + length);
                vertices.Add(v_in);
            }
            {
                float t = theta * Mathf.Deg2Rad;
                var v_out = new Vector3(radius - (radius + width) * Mathf.Cos(t), 0, (radius + width) * Mathf.Sin(t) + length);
                vertices.Add(v_out);
                var v_in = new Vector3(radius - (radius - width) * Mathf.Cos(t), 0, (radius - width) * Mathf.Sin(t) + length);
                vertices.Add(v_in);
            }
        }
        else if (theta < 0)
        {
            // 反時計回り
            for (int i = 0; i < (int)(-theta / DIV_THETA); i++)
            {
                float t = (float)(DIV_THETA * i) * Mathf.Deg2Rad;
                var v_in = new Vector3(-radius + (radius - width) * Mathf.Cos(t), 0, (radius - width) * Mathf.Sin(t) + length);
                vertices.Add(v_in);
                var v_out = new Vector3(-radius + (radius + width) * Mathf.Cos(t), 0, (radius + width) * Mathf.Sin(t) + length);
                vertices.Add(v_out);
            }
            {
                float t = -theta * Mathf.Deg2Rad;
                var v_in = new Vector3(-radius + (radius - width) * Mathf.Cos(t), 0, (radius - width) * Mathf.Sin(t) + length);
                vertices.Add(v_in);
                var v_out = new Vector3(-radius + (radius + width) * Mathf.Cos(t), 0, (radius + width) * Mathf.Sin(t) + length);
                vertices.Add(v_out);
            }

        }

        return vertices;
    }

}

public class RoadScript : MonoBehaviour
{

    // セグメントリスト。ここで道路の形状を設定する。
    public List<RoadSegment> segments;

    // 頂点列
    private List<Vector3> vertices;
    // 三角形列
    private List<int> triangles;
    // テクスチャ用UV列
    private List<Vector2> uvs;

    // 全てのセグメントの頂点列をつないで、三角形列、テクスチャ用UV列を生成する。
    void makeVerticesFromSegments()
    {
        if (segments != null)
        {
            Vector3 pos = new Vector3(0, 0, 0);
            float theta = 0;
            vertices = new List<Vector3>();

            foreach (var seg in segments)
            {
                var seg_vertices = seg.makeVertices();
                // 回転行列
                var q = Quaternion.AngleAxis(theta, Vector3.up);

                if (vertices.Count > 1)
                {
                    // 接続位置の座標は重複するので削除する。
                    vertices.RemoveRange(vertices.Count - 2, 2);
                }
                // 接続位置の座標と向きを使って回転・移動させる。
                foreach (var vt in seg_vertices)
                {
                    vertices.Add(q * vt + pos);
                }
                // 次の接続位置の座標と向きを計算する。
                pos = (vertices[vertices.Count - 2] + vertices[vertices.Count - 1])/2;
                theta += seg.theta;
            }
        }

        // 頂点列の並びから、三角形列は下記のように決定できる。
        triangles = new List<int>();
        for (int i = 0; i < (vertices.Count - 2); i += 2)
        {
            triangles.Add(i + 0);
            triangles.Add(i + 2);
            triangles.Add(i + 1);
            triangles.Add(i + 1);
            triangles.Add(i + 2);
            triangles.Add(i + 3);
        }

        // UV列は道路テクスチャのパターンを繰り返し適用するため、下記のように設定する。
        uvs = new List<Vector2>();
        for (int i = 0; i < vertices.Count; i += 2)
        {
            uvs.Add(new Vector2(0, i / 2));
            uvs.Add(new Vector2(1, i / 2));
        }
    }


    // Start is called before the first frame update
    void Start()
    {

       if (segments != null)
       {
            // Meshに頂点列、三角形列、UV列を設定する。
            var mesh = new Mesh();
            makeVerticesFromSegments();
            mesh.SetVertices(vertices);
            mesh.SetTriangles(triangles, 0);
            mesh.RecalculateNormals(); // 法線の再計算をしないとライティングがバグる。
            mesh.SetUVs(0, uvs);

            // MeshFilterにMeshを設定すると、MeshRendererがいい感じで道路を描画してくれる。
            var meshFilter = GetComponent<MeshFilter>();
            meshFilter.mesh = mesh;
        }
    }


    // Update is called once per frame
    void Update()
    {
        
    }

    void OnValidate()
    {
        // RoadSegmentに変更があったときに再描画する。
        Start();
    }
}
```

# 参考にしたサイト

技術的な話はこれらのサイトを見るとだいたいわかります。もちろん、UnityのDocumentも参考になります。

https://matcha-choco010.net/2018/08/25/unity-mesh-from-script/

https://www.shibuya24.info/entry/2015/11/29/180748

https://mokapants.hatenablog.com/entry/2021/06/09/213702

# さいごに

久々に三角関数と戯れて楽しかったです。