UnityのGLを使って数なし数直線を作ってみた

#はじめに

Unityで画面上に線を引くためには様々な手段がありますが、その中でも**[GL](https://docs.unity3d.com/ja/current/ScriptReference/GL.html)**と言うOpenGLのイミディエイトモードと同様のコマンドを実行することができるグラフィックスライブラリがあるらしいです。

今回はそれを使って**数直線（数なし）**←は？を作ってみたのでご紹介したいと思います。
※2D上でしか検証していないので3Dでの動作は保証できません


コードはGithubにて公開しています。https://github.com/HarumaroJP/GLMarkLineDrawer

![run.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409758/026bed65-7a8d-6fca-e0b3-d2262b2ed5ff.gif)

#使い方

Githubに公開している二つのスクリプトをUnityに入れて、GLMarkLineDrawer.csをGameObjectにくっつけるだけです。

![スクリーンショット 2020-10-10 17.02.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409758/d04f21ec-46f5-baad-dddc-069af61475a7.png)

設定欄はこんな感じです。

| 名前 | 用途 |
|:-----------:|:------------:|
| Paths       |Transform型で線の通る座標を指定します        |
| Color     | 数直線の色を指定します      |
| Width       | 線の幅を指定します        |
| Edge Mark Length         | 外側のメモリの長さを指定します。          |
| Inside Mark Length       | 内側のメモリの長さを指定します。       | 
| Interval Count    | メモリの分割数を指定します。     |

最初はVector3型で指定するようにしようかなと思ったのですが、かなり面倒になるので、Transform型を使うことにしました。

#GLについて

まず前提としてGLクラスでは、このような書き方で描画します。

```c#
        //頂点マトリックスが漏れないようにするためのおまじない
        GL.PushMatrix();
        {
            //描画開始
            GL.Begin(GL.QUADS);
            {
                GL.Color(Color.white);

                GL.Vertex3(x,y,z);
            }
            GL.End();
        }
        GL.PopMatrix();
```


・```GL.PushMatrix() , GL.PopMatrix()```は、行列マトリックスが範囲外に漏れないようにするためのおまじないのようなものです。複雑な描画をする際には必要になります。今回は一応書いていますが、簡単な描画の場合は必要ないかも..?

・```GL.Begin() , GL.End()```の間で、描画する頂点を指定することができます。また、引数でメッシュの形成方法を指定する方法ができます。「GL Primitives」で検索するといろいろな方法が出てくるので、調べてみてください。今回は、四角形を形成するように設定しています。

・`GL.Color`は色を設定するためのメソッドです。このメソッドで設定して実行された後に面が描画された場合、設定した色が反映されるようになります。

・`GL.Vertex3(x,y,z) or GL.Vertex(Vector3)`で頂点を設定することができます。

またブロック文で囲っている箇所がありますが、これは特に必要というわけではありません。可読性が上がるので、付けた方が良いというだけです。

#Drawerの解説
一旦コードを貼ります。

```c#:GLMarkLineDrawer.cs

using System;
using UnityEngine;

[ExecuteInEditMode]
public class GLMarkLineDrawer : MonoBehaviour {

    [SerializeField]
    private GLMarkLine.LineSettings settings =
        new GLMarkLine.LineSettings(new Transform[0], Color.white, 100f, 2f, 1f, 10);

    private GLMarkLine line = new GLMarkLine(); //ラインのインスタンスを生成


    private void OnRenderObject() {
        line.Draw(settings); //描画
    }
}
```

こちらの方はあまり説明は必要ないと思いますが、

一応補足をしておくと、Monobehaviourの```OnRenderObject()```はカメラがシーンをレンダリングした後に呼び出されるメソッドで、今回はこのメソッドを通して描画しています。

また設定のパラメータは構造体で管理しています。あまり構造体で自前のコンストラクタは使いたくなかったのですが、入れたらすぐ使えるようにしたかったので、今回は自前のコンストラクタで初期化しています。


#本体の解説

次が本体のコードです。

```c#:GLMarkLine.cs

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;

public class GLMarkLine {

    //設定パラメータ用のstruct
    [Serializable]
    public struct LineSettings {
        public Transform[] paths;
        public Color color;
        public float width;
        public float edgeMarkLength;
        public float insideMarkLength;
        public int intervalCount;


        public LineSettings(Transform[] paths,
                            Color color,
                            float width,
                            float edgeMarkLength,
                            float insideMarkLength,
                            int intervalCount) {
            this.paths = paths;
            this.color = color;
            this.width = width;
            this.edgeMarkLength = edgeMarkLength;
            this.insideMarkLength = insideMarkLength;
            this.intervalCount = intervalCount;
        }
    }


    public LineSettings settings;
    private LineSettings _settings;
    private IReadOnlyList<Vector3> _paths = new List<Vector3>();
    private List<Vector3> currentPaths = new List<Vector3>();
    private Material lineMaterial;
    private float relativeWidth;
    public bool enabled = true;

    private static readonly int SrcBlend = Shader.PropertyToID("_SrcBlend");
    private static readonly int DstBlend = Shader.PropertyToID("_DstBlend");
    private static readonly int Cull = Shader.PropertyToID("_Cull");
    private static readonly int ZWrite = Shader.PropertyToID("_ZWrite");

    //描画するために使うマテリアルの初期化
    private void InitMaterial() {
        if (!lineMaterial) {
            lineMaterial = new Material(Shader.Find("Hidden/Internal-Colored"));
            lineMaterial.hideFlags = HideFlags.HideAndDontSave;
            lineMaterial.SetInt(SrcBlend, (int) BlendMode.SrcAlpha);
            lineMaterial.SetInt(DstBlend, (int) BlendMode.OneMinusSrcAlpha);
            lineMaterial.SetInt(Cull, (int) CullMode.Off);
            lineMaterial.SetInt(ZWrite, 0);
        }
    }


    public void Draw(LineSettings currentSetting) {
        settings = currentSetting;
        //座標が入っていなかったら描画しない
        if (!enabled || settings.paths.Length <= 0 || settings.paths.Any(t => t == null)) return;
        IReadOnlyList<Vector3> vecPaths = settings.paths.Select(t => t.position).ToList();


        InitMaterial();
        lineMaterial.SetPass(0);
        GL.PushMatrix();
        {
            GL.Begin(GL.QUADS);
            {
                GL.Color(settings.color);
 
                //設定が変更されていなかったら計算しない
                if (_settings.Equals(settings) && _paths.SequenceEqual(vecPaths)) {
                    DotVertexes(currentPaths.ToArray());
                }
                else {
                    currentPaths.Clear();
                    _settings = settings;
                    _paths = vecPaths;

                    Vector3 v0, v1, o;
                    //解像度に対する幅を求める
                    relativeWidth = 1.0f / Screen.width * settings.width * 0.5f;

                    for (int index = 0; index < _paths.Count - 1; index++) {
                        v0 = _paths[index];
                        v1 = _paths[index + 1];
                        //２点の単位ベクトルを求める
                        o = (new Vector3(v1.y, v0.x, 0.0f) - new Vector3(v0.y, v1.x, 0.0f)).normalized;

                        DrawLine2D(v0, v1, o);
                        DrawMark2D(v0, v1, o);
                    }
                }
            }
            GL.End();
        }
        GL.PopMatrix();
    }

    //２点に線を引く関数
    void DrawLine2D(Vector3 v0, Vector3 v1, Vector3 o) {
        Vector3 n = o * relativeWidth;
        Vector3[] vertex = new[] {
            new Vector3(v0.x - n.x, v0.y - n.y, 0.0f),
            new Vector3(v0.x + n.x, v0.y + n.y, 0.0f),
            new Vector3(v1.x + n.x, v1.y + n.y, 0.0f),
            new Vector3(v1.x - n.x, v1.y - n.y, 0.0f),
        };

        DotVertexes(vertex);

        foreach (Vector3 v in vertex) {
            currentPaths.Add(v);
        }
    }

    //2点にメモリをつける関数
    void DrawMark2D(Vector3 v0, Vector3 v1, Vector3 o) {
        Vector3 markLength, _v0, _v1, _o;
        Vector3 _unitVec = (v1 - v0) / settings.intervalCount;
        List<Vector3> _pos = new List<Vector3>();

        for (int i = 0; i < settings.intervalCount + 1; i++) {
            _pos.Add(v0 + _unitVec * i);
        }

        for (int i = 0; i < _pos.Count; i++) {
            Vector3 vec = _pos[i];
            float length = (i == 0 || i == _pos.Count - 1 ? settings.edgeMarkLength : settings.insideMarkLength);

            markLength = o * length;
            _v0 = new Vector3(vec.x - markLength.x, vec.y - markLength.y, 0.0f);
            _v1 = new Vector3(vec.x + markLength.x, vec.y + markLength.y, 0.0f);
            _o = (new Vector3(_v1.y, _v0.x, 0.0f) - new Vector3(_v0.y, _v1.x, 0.0f)).normalized;

            DrawLine2D(_v0, _v1, _o);
        }
    }

    //与えられた座標配列に頂点を打つ関数
    void DotVertexes(Vector3[] pos) {
        foreach (Vector3 v in pos) {
            GL.Vertex3(v.x, v.y, v.z);
        }
    }
}
```

コードが長くなっているので、細かい説明は割愛します。

###１、数直線に幅を持たせたい
Unity側で用意されているGLクラスには、ラインの幅を設定するための方法が用意されていません。
なので、自分でメッシュの頂点座標を計算して描画する必要があります。（OpenGLの方だとあるらしい）

![スクリーンショット 2020-10-10 20.23.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409758/f7a6b9ca-8bfc-ab5f-44df-b7181f9a0a22.png)

実装方法としては、ある直線があったとしてその直線を囲む四角形の頂点座標を算出し、描画すれば作ることができます。（要するに赤い点の座標を求めたい）


ここからは少し、数学の話になります。
下のテキストを見てください。

![スクリーンショット 2020-10-10 21.13.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409758/0e48ddaf-5cf6-a9af-e7ac-8b2a15a02006.png)

ここでは、上の図のような座標があったとして、まず**頂点との差分（緑の線の部分）**を求めます。
左のページでは、二つの頂点座標から垂直なベクトルを求めています。しかし、これだけだと二つの頂点の距離によって緑の線の長さも**変わってしまいます**。

そこで**長さを1に固定した単位ベクトル**を求めることで、それに適当な幅をかけると狙い通りの長さにすることができます。


![スクリーンショット 2020-10-10 21.40.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409758/3b443ca1-be5d-26b8-e84f-2428ef54ee37.png)
そして最後にその差分を、元の頂点座標に足し合わせることで４点が求まります。


この部分の実装はこのようになっています。

```c#
    //垂直なベクトルを求め、正規化する
    o = (new Vector3(v1.y, v0.x, 0.0f) - new Vector3(v0.y, v1.x, 0.0f)).normalized;

    void DrawLine2D(Vector3 v0, Vector3 v1, Vector3 o) {
        //単位ベクトルに設定された幅をかける
        Vector3 n = o * relativeWidth;
        
        //オフセットを足して、頂点座標を求める
        Vector3[] vertex = new[] {
            new Vector3(v0.x - n.x, v0.y - n.y, 0.0f),
            new Vector3(v0.x + n.x, v0.y + n.y, 0.0f),
            new Vector3(v1.x + n.x, v1.y + n.y, 0.0f),
            new Vector3(v1.x - n.x, v1.y - n.y, 0.0f),
        };

        //描画する（自作関数）
        DotVertexes(vertex);
    }

```

###２、数直線にメモリを付けたい

お気づきの方もいるかもしれませんが、1番の方法を利用すれば簡単に作ることができます。

![スクリーンショット 2020-10-10 21.58.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409758/e2c53d9e-50f0-5f2a-40fd-393808d966ad.png)

先ほどの４点をそれぞれv0,v1とし、新たにまた4点を求めるだけです。

#まとめ
これを実装するのに約半日潰してしまった...
多分LineRendererよりは軽くなっている気がする（気がするだけ）。
正直線を引くだけならLineRendererで良い気がします。たくさん線を引きたいのであれば、GLの方が高速かも？

たくさん機能を追加すれば、もっと利便性は上がると思います。でも僕はやりません（）