凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity の Editor 拡張でインスペクタにグラフを描画する方法を色々調べてみた

はじめに

Unity でデータの解析を行ったりする際、開発時にそのデータを可視化したいことがあると思います。例えば音声入力やハードウェアからの入力や WEB から取ってきた何らかのデータ、ゲームの統計情報などなど色々考えられます。アプリケーション内には反映させたくないけれどデバッグ用に開発時には見たい、と思い立ったときにどうやれば良いか予め知っておくと便利だと思います。

そこで、Unity の Editor 拡張を利用して「グラフを描く」という観点に焦点を当てて色々と調べてみました。本エントリでは、Editor Charts という既存のアセットを利用する方法および自前で描画する方法を2種類紹介し、これらのメリット・デメリットを考察した内容をご紹介します。

Editor Charts

まず、テラシュールウェアさんで紹介されている Editor Charts というアセットを利用してみます。

アセットを Import すると EditorCharts/Samples 以下にサンプルシーンとスクリプトが含まれているのでこれを見てみます。

f:id:hecomi:20140623001900p:plain

するとこんな折れ線グラフが描かれます。シーン中の GameObject には TestInspector.cs がアタッチされていて、このインスペクタ上の体裁を TestInspectorEditor.cs で指定している形になります。コードを見てみます(一部改変)。

TestInspector.cs

using UnityEngine;
using System.Collections;

public class TestInspector : MonoBehaviour 
{
    public int random;
    
    void Update() 
    {
        random = Random.Range(0, 50);
    }
}

ランダムな値をマイフレーム更新して突っ込んでいるだけです。

TestInspectorEditor.cs

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;

[CustomEditor(typeof(TestInspector))]
public class TestInspectorEditor : Editor
{
    private const int maxNumbers = 50;
    private LineChart lineChart;
    private List<float> numbers = new List<float>();
    
    override public void OnInspectorGUI() 
    {
        // TestInspector で生成したランダム値をリストに保存
        if (EditorApplication.isPlaying && !EditorApplication.isPaused) {
            numbers.Add( (target as TestInspector).random );
        }
        // 保存件数が 100 を超えたら手前から削除
        if (numbers.Count > 100) numbers.RemoveAt(0);

        // 折れ線グラフを生成
        if (lineChart == null) {
            lineChart = new LineChart(this, 200.0f);
        }

        // データと体裁をセット
        lineChart.data = new List<float>[]{numbers};
        lineChart.pipRadius = 1.0f;
        lineChart.drawTicks = false;

        // 折れ線グラフを描画
        // NOTE: Handles.Begin/EndGUI は必要なし?
        Handles.BeginGUI();
        lineChart.DrawChart();
        Handles.EndGUI();
    }
}

基本はこんな形で描画を行うようです。データの形式や指定できる描画用のパラメタが異なりますが、円グラフ(PieChart) / 棒グラフ(BarChart)に関しても似たようなデータ指定で可能です。サンプルはSamples/Editor/AnalyticsWindow.cs に含まれていて、Unity の「Window > Analytics Sample」から開いてみることが出来ます。

f:id:hecomi:20140623001748p:plain

ドキュメントはないですが、MonoDevelop の補完機能などを借りながらポチポチ指定していけば簡単に作成できると思います。

自前で作成する

しかしながらもっと自由な体裁にしたい、みたいな時もあると思います。その方法を 2 種類試してみましたのでご紹介します。

DrawLine.cs の利用

まず、上記 Unity Forums に記載されている yoyo さんの書き込み(ノートの画像で数学的な解説をしているレス) から DrawLine.cs をコピペして自分のプロジェクトに加えます。

これを利用すると先ほどのような折れ線グラフは以下のように記述可能です。

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

[CustomEditor(typeof(OriginalInspector))]
public class OriginalInspectorEditor : Editor
{
    private List<float> data_ = new List<float>();

    override public void OnInspectorGUI()
    {
        EditorGUILayout.Space();

        if (Application.isPlaying) {
            AddData( (target as CustomInspector).random );
        }

        var area = GUILayoutUtility.GetRect(Screen.width, 200f);

        // Grid
        const int div = 10;
        for (int i = 0; i <= div; ++i) {
            var lineColor = (i == 0 || i == div) ? Color.white : Color.gray;
            var lineWidth = (i == 0 || i == div) ? 2f : 1f;
            var x = (area.width  / div) * i;
            var y = (area.height / div) * i;
            Drawing.DrawLine (
                new Vector2(area.x + x, area.y),
                new Vector2(area.x + x, area.yMax), lineColor, lineWidth, true);
            Drawing.DrawLine (
                new Vector2(area.x,    area.y + y),
                new Vector2(area.xMax, area.y + y), lineColor, lineWidth, true);
        }

        // Data
        if (data_.Count > 0) {
            var max = data_.Max();
            var dx  = area.width / data_.Count; 
            var dy  = area.height / max;
            Vector2 previousPos = new Vector2(area.x, area.yMax); 
            for (var i = 0; i < data_.Count; ++i) {
                var x = area.x + dx * i;
                var y = area.yMax - dy * data_[i];
                var currentPos = new Vector2(x, y);
                Drawing.DrawLine(previousPos, currentPos, Color.red, 3f, true);
                previousPos = currentPos;
            }
        }

        EditorGUILayout.Space();
    }

    private void AddData(float value)
    {
        data_.Add(value);
        if (data_.Count > 100) {
            data_.RemoveAt(0);
        }
    }
}

f:id:hecomi:20140623214522p:plain

GUILayoutUtility.GetRect() で描画領域を貰ってきて、それを利用して絶対座標ベースでゴリゴリと描いていますが、それほど大変ではありません。

ちょっと仕組みを見てみます。フォーラムで論じられているものは Unity の Wiki に投稿されているものを改造したよ、という内容なので、まずオリジナル版を見てみます。

ここではトリッキーなことをしていて、1x1 ピクセルのテンポラリなテクスチャを生成して、それを与えられた始点 / 終点から計算したスケール / 向きを GUIUtility.ScaleAroundPivot()GUIUtility.RotateAroundPivot() を使って変形行列である GUI.marix を書き換え、GUI.DrawTexture() で描画、という形になっています。Unity で 3D の線を描画するには Line RendererMeshGL.LINES などがありますが、2D の線を描画する方法として提案されたようです。

Wiki 版に対して、Forums 版の方ではアンチエイリアスをかけるために用意した 1x3 ピクセルのテクスチャを利用できるようになっています(Linusmartensson さんによるスレッド最初の書き込みの unitypackage を参照)。そして本項で利用した yoyo さん版では更にこれを改良し、自前で GL matrix をゴリゴリ計算して Graphics.DrawTexture() することで、GUI.DrawTexture() したものより高速化を実現しているようです。うーん、奥が深い。

UnityEditor.Handles を利用

別の方法である UnityEditor.Handles を使う方法を見てみます。Handles は 3D のコントロール用 UI をカスタマイズするように用意されたクラスです。

Scene ビュー内でカスタムされた GUI を表示したいときに便利です。Handles の関数のリンク先を見てみるとイメージが湧くと思います。

さて、この Handles ですが、Inspector 内でも使えるようです。先ほどの Editor Charts はこの方法でグラフを描画していました。例えば以下の様なコードを書いてみます。

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(CustomInspector))]
public class CustomInspectorEditor : Editor 
{
    public override void OnInspectorGUI()
    {
        Rect area = GUILayoutUtility.GetRect(Screen.width, 200.0f);
        Handles.DrawSolidDisc(area.center, Vector3.forward, 80f);
    }
}

すると、以下のように円が描画されます。

f:id:hecomi:20140623200842p:plain

ドキュメントをざっと見てみると、グラフに必要そうな Line 系も沢山用意されています。

  • DrawLine
    • 2 点間の線を描画
  • DrawPolyLine
    • 複数点間の折れ線を描画
  • DrawAAPolyLine
  • DrawDottedLine
    • 点線を描画
  • DrawBezier

これらを利用してグラフを描画してみます。

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

[CustomEditor(typeof(CustomInspector))]
public class CustomInspectorEditor : Editor 
{
    private List<float> data_ = new List<float>();

    public override void OnInspectorGUI()
    {
        if (Application.isPlaying) {
            AddData();
        }

        EditorGUILayout.Space();
        DrawGraph();
        EditorGUILayout.Space();
    }

    private void AddData()
    {
        data_.Add( (target as CustomInspector).random );
        if (data_.Count > 100) data_.RemoveAt(0);
    }

    private void DrawGraph()
    {
        Rect area = GUILayoutUtility.GetRect(Screen.width, 200f);

        // axis
        Handles.DrawSolidRectangleWithOutline(new Vector3[] {
            new Vector2(area.x,    area.y),
            new Vector2(area.xMax, area.y),
            new Vector2(area.xMax, area.yMax),
            new Vector2(area.x,    area.yMax)
        }, new Color(0,0,0,0), Color.white); 

        // grid
        Handles.color = new Color(1f, 1f, 1f, 0.5f);
        const int div = 10;
        for (int i = 1; i < div; ++i) {
            float y = area.height / div * i;
            float x = area.width  / div * i;
            Handles.DrawLine(
                new Vector2(area.x,    area.y + y),
                new Vector2(area.xMax, area.y + y));
            Handles.DrawLine(
                new Vector2(area.x + x, area.y),
                new Vector2(area.x + x, area.yMax));
        }

        // data
        Handles.color = Color.red;
        if (data_.Count > 0) {
            var points = new List<Vector3>(); 
            var max = data_.Max();
            var dx  = area.width / data_.Count;
            var dy  = area.height / max;
            for (var i = 0; i < data_.Count; ++i) {
                var x = area.x + dx * i;
                var y = area.yMax - dy * data_[i]; 
                points.Add(new Vector2(x, y));
            }
            Handles.DrawAAPolyLine(5f, points.ToArray());
        }
    }
}

f:id:hecomi:20140623205627p:plain

同じように書けますね。

メリット/デメリット

アセットを利用

アセットを使うのが一番お手軽です。ただ、予め決められた体裁の範囲内で作成することになるので、例えば1次元の折れ線グラフでなくて 2 次元...とかなると対応できなくなります。

DrawLines

Drawing.DrawLines は主に線しか描画出来ないのが欠点です。しかしながら仕組み的に Editor でなくて Game ビューでも使えるのが利点で、このメリットは結構強いです。例えば先ほどの Editor スクリプトを以下のように MonoBehaviourスクリプトに移植してみると、そのまま動作します。

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

public class CustomInspector : MonoBehaviour 
{
    private List<float> data_ = new List<float>();

    void Update() 
    {
        data_.Add(Random.Range (0, 50));
        if (data_.Count > 100) {
            data_.RemoveAt(0);
        }
    }

    void OnGUI()
    {
        var area = GUILayoutUtility.GetRect(Screen.width, Screen.height);

        // Grid
        const int div = 10;
        for (int i = 0; i <= div; ++i) {
            var lineColor = (i == 0 || i == div) ? Color.white : Color.gray;
            var lineWidth = (i == 0 || i == div) ? 2f : 1f;
            var x = (area.width  / div) * i;
            var y = (area.height / div) * i;
            Drawing.DrawLine (
                new Vector2(area.x + x, area.y),
                new Vector2(area.x + x, area.yMax), lineColor, lineWidth, true);
            Drawing.DrawLine (
                new Vector2(area.x,    area.y + y),
                new Vector2(area.xMax, area.y + y), lineColor, lineWidth, true);
        }

        // Data
        if (data_.Count > 0) {
            var max = data_.Max();
            var dx  = area.width / data_.Count; 
            var dy  = area.height / max;
            Vector2 previousPos = new Vector2(area.x, area.yMax); 
            for (var i = 0; i < data_.Count; ++i) {
                var x = area.x + dx * i;
                var y = area.yMax - dy * data_[i];
                var currentPos = new Vector2(x, y);
                Drawing.DrawLine(previousPos, currentPos, Color.red, 3f, true);
                previousPos = currentPos;
            }
        }
    }
}

f:id:hecomi:20140623221145p:plain

Handles

そして最後に Handles は、Editor でしか使えないのが欠点ですが、Editor Charts で見られるように多彩な表現が可能なのが利点です。こだわりがあってリッチな感じにカスタマイズしたかったらこれだと思います。

どれが一番良いとは言えないので、その時々の自分のユースケースにあった方法を選ぶのが良さそうです。

その他

有料ですが色々と便利に使えるアセットもあるようです。

他にも探せば色々あるかもしれません。

おわりに

今回は「グラフを描く」という観点に絞った紹介を行いましたが、もう少し知識がついたらより汎用的な解説が書ければ、と思います。