LoginSignup
75
61

More than 3 years have passed since last update.

【Unity】ゼロから作るノードベースエディター【UIElements】

Last updated at Posted at 2019-12-21

概要

UnityのUIElementsによってこのようなノードベースエディタを作ります。
NodeEditor-36.gif

タイトルの「ゼロから」は、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。

Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor/tree/article

2019/12/23 追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。
2020/01/09 追記:上記の不具合を修正し、反映しました。変更ファイルはEdgeElement.csとEdgeConnector.csのみです。

背景

Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。

最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。

shader_graph_sample.jpg
[画像は引用:https://unity.com/ja/shader-graph]

これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]

さて、本記事の目標はGraphViewのようなのツールを作ることです。

いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。

とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。

注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。

実装

公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html

0. 挨拶

エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。

Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。

とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]  // Unityのメニュー/Window/GraphEditorから呼び出せるように
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();  // ウィンドウを作成。
        graphEditor.Show();  // ウィンドウを表示
        graphEditor.titleContent = new GUIContent("Graph Editor");  // Windowの名前の設定
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));
    }
}

Unity公式ブログのはじめの例を参考にしました。

ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。

さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。

NodeEditor-0.PNG

こんにちは!
ひとまず、挨拶は終わりました。

1. ノードを表示する

Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。

ドキュメントには、UIElementの構造の説明として、このような図がありました。
visualtree-hierarchy.png
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。

というわけでいろいろ試してみます。

1.1 表示場所を指定する

ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
    }
}
// NodeElement.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeElement : VisualElement
{
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        transform.position = pos;

        Add(new Label(name));
    }
}

先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。

このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。

以下、結果です。
NodeEditor-1.PNG
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。

もう一つ追加してみましょう。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

NodeEditor-2.PNG
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。

本当は、
NodeEditor-3.PNG
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32)));  // y座標を変更
    }

どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。

探すと別のドキュメントにありました。

Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.

positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。

NodeElementのコンストラクタを以下のように書き換えます

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;  // 追加。これがposition propertyらしい
        transform.position = pos;

        Add(new Label(name));
    }

すると、

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

は以下のようになります。
NodeEditor-4.PNG

ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。

これで位置を自由に指定できるようになりました。

1.2 大きさを指定する

次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.styleにまとまっているようで、以下のように指定します。

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50,
        style.width = 100,
        transform.position = pos;

        Add(new Label(name));
    }

すると以下のようになります。
NodeEditor-5.PNG

初めに言った、red containerのようになったと思います。
visualtree-hierarchy.png

2. ノードを動かす

次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。

挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。

2.1 まずは試してみる

これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()によってコールバックを登録できるみたいですね。

マウスに関するイベントはそれぞれ、
1. MouseDownEvent
2. MouseMoveEvent
3. MouseUpEvent
でとることができそうです。

NodeElementクラスを以下のように書き換えます。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        bool focus = false;

        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)  // 左クリック
            {
                focus = true;  // 選択
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            focus = false;  // 選択を解除
        });

        RegisterCallback((MouseMoveEvent evt) =>
        {
            if (focus)
            {
                transform.position += (Vector3)evt.mouseDelta;  // マウスが動いた分だけノードを動かす
            }
        });
    }

すると、以下のような挙動になります。
NodeEditor-7.gif
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEventが赤いノードに対して呼ばれていないことが問題のようです。

改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。

2.2 VisualElementの表示順を変える

ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。

Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling list

The only way to change their drawing order is to reorder VisualElementobjects in their parents.

描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。

BringToFront()というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();  // 自分を最前面に持ってくる
            }
        });

実行結果は以下です。
NodeEditor-8.gif
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。

2.3 マウスイベントをキャプチャする

マウスを勢いよく動かしたとき、カーソルがノードの外に出るのでMouseLeaveEventが呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEventが選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。

いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。

VisualElementはCaptureMouse()を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。

ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();
                CaptureMouse();  // マウスイベントをキャプチャ
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            ReleaseMouse();  // キャプチャを解放
            focus = false;
        });

        RegisterCallback((MouseCaptureOutEvent evt) =>
        {
            m_Focus = false;  // キャプチャが外れたときはドラッグを終了する
        }

MouseCaptureOutEventは他のVisualElementなどによってキャプチャを奪われたときに呼ばれる関数です。

実行結果は以下になります。
NodeEditor-9.gif
無事に意図した動きになりました。

2.4 ノードを動かすコードをManipulatorによって分離する

この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。

NodeDraggerというクラスを作成します。

// NodeDragger.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeDragger : MouseManipulator
{
    private bool m_Focus;

    public NodeDragger()
    {
        // 左クリックで有効化する
        activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
    }

    /// Manipulatorにターゲットがセットされたときに呼ばれる
    protected override void RegisterCallbacksOnTarget()
    {
        m_Focus = false;

        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    /// Manipulatorのターゲットが変わる直前に呼ばれる
    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        // 設定した有効化条件をみたすか (= 左クリックか)
        if (CanStartManipulation(evt))
        {
            m_Focus = true;
            target.BringToFront();
            target.CaptureMouse();
        }
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        // CanStartManipulation()で条件を満たしたActivationのボタン条件と、
        // このイベントを発火させているボタンが同じか
        // (= 左クリックを離したときか)
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();
            m_Focus = false;
        }
    }

    protected void OnMouseCaptureOut(MouseCaptureOutEvent evt)
    {
        m_Focus = false;
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (m_Focus)
        {
            target.transform.position += (Vector3)evt.mouseDelta;
        }
    }
}

RegisterCallBacksOnTarget()UnregisterCallbacksFromTarget()Manipulatorクラスの関数で、イベントのコールバックの登録・解除を担っています。
activatorsCanStartManipulation()CanStopManipulation()Manipulatorクラスを継承するMouseManipulatorクラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。

このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        AddManipulator(new NodeDragger());  // 操作の追加が一行で済む
    }

AddManipulatorという関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。

        new NodeDragger(){target = container};

内部の実装を見ると、AddManipulatorではIManipulator.targetプロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()を呼びます。

[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]

3. ノードを追加する

これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、

  1. 右クリックでメニューが出てくる
  2. 「Add Node」を選択する
  3. 右クリックした場所にノードが生成される

です。

......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。

3.1 メニューを表示する

2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。

コードを載せます。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        // 項目を追加
        evt.menu.AppendAction(
            "Add Node",  // 項目名
            AddEdgeMenuAction,  // 選択時の挙動
            DropdownMenuAction.AlwaysEnabled  // 選択可能かどうか
            );
    }

    void AddEdgeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

さあ、どうでしょうか。
NodeEditor-10.gif
上手く動いていません。

期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
NodeEditor-11.PNG
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]

イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。

とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingModePickingMode.Ignoreに設定されているということでした。

リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、このpickingModePickingMode.Ignoreに設定されていた場合は候補から外す、という挙動になるようです。

実際、このようにすると動きます。

// GraphEditorクラス

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.pickingMode = PickingMode.Position;  // ピッキングモード変更

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

NodeEditor-12.gif
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。

さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    public GraphEditorElement()
    {
        style.flexGrow = 1;  // サイズを画面いっぱいに広げる
        style.overflow = Overflow.Hidden;  // ウィンドウの枠からはみ出ないようにする

        Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        evt.menu.AppendAction(
            "Add Node",
            AddNodeMenuAction,
            DropdownMenuAction.AlwaysEnabled
            );
    }

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

二つのスタイルを適用しました。
style.flexGrow = 1; によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden; は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。

挙動はこのようになります。
NodeEditor-13.gif
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。

後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。

とにかくメニューは出たということで、次へ進んでいきます。

3.2 ノードを生成する

Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。

// GraphEditorElementクラス
    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;  // マウス位置はeventInfoの中にあります

        Add(new NodeElement("add", Color.green, mousePosition));
    }

挙動です。
NodeEditor-14.gif
これで、表示の上では新しいノードを生成できました。

4. ノードを永続化する

3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。

「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。

4.1 グラフ構造とは

方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。

グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
graph_sample.jpg
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]

エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。

ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移

ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。

4.2 シリアライズ用のクラスを作る

構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つ

として、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。

// GraphAsset.cs
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")]
public class GraphAsset : ScriptableObject
{
    public List<SerializableNode> nodes = new List<SerializableNode>();
}

[System.Serializable]
public class SerializableNode
{
    public Vector2 position;
    public List<SerializableEdge> edges = new List<SerializableEdge>();
}

[System.Serializable]
public class SerializableEdge
{
    public SerializableNode toNode;
}

ScriptableObject[CreateAssetMenu]を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]アトリビュートによって、指定したクラスをシリアライズ可能にしています。

早速、グラフアセットを作ってみました。
NodeEditor-15.gif
すると、このようなエラーが出ます。

Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes.

Serialization hierarchy:
8: SerializableEdge.toNode
7: SerializableNode.edges
6: SerializableEdge.toNode
5: SerializableNode.edges
4: SerializableEdge.toNode
3: SerializableNode.edges
2: SerializableEdge.toNode
1: SerializableNode.edges
0: GraphAsset.nodes

UnityEditor.InspectorWindow:RedrawFromNative()

これはつまり、こういうことです。
NodeEditor-16.gif

そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。

では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。

SerializableEdgeだけ以下のように直します。

// GraphAsset.cs
[System.Serializable]
public class SerializableEdge
{
    public int toId;
}

これでワーニングは出なくなります。

4.3 アセットとエディタを対応付ける

どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。

というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。

// GraphAsset.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;  // OnOpenAssetアトリビュートのために追加
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");

        if(Selection.activeObject is GraphAsset graphAsset)
        {
            graphEditor.Initialize(graphAsset);
        }
    }

    [OnOpenAsset()]  // Unityで何らかのアセットを開いたときに呼ばれるコールバック
    static bool OnOpenAsset(int instanceId, int line)
    {
        if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset)  // 開いたアセットがGraphAssetかどうか
        {
            ShowWindow();
            return true;
        }

        return false;
    }

    GraphAsset m_GraphAsset;  // メンバ変数として持っておく
    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        // ShowWindow()を通らないような時(スクリプトのコンパイル後など)
        // のために初期化への導線を付ける
        if (m_GraphAsset != null)
        {
            // 初期化はInitializeに任せる
            Initialize(m_GraphAsset);
        }
    }

    // 初期化
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        // 以下はもともとOnEnable() で行っていた処理
        // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない
        // 初期化でもgraphAssetを使うことになるのでここに移す
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
NodeEditor-17.gif

4.4 アセットのデータからノードを表示するようにする

続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
NodeEditor-18.PNG
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。

まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。

具体的には、生成をSerializableNodeから行うようにします。

// NodeElement.cs

// BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する
public class NodeElement : Box  
{
    public SerializableNode serializableNode;

    public NodeElement (SerializableNode node)  // 引数を変更
    {
        serializableNode = node;  // シリアライズ対象を保存しておく

        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = node.position;  // シリアライズされている位置を取る

        this.AddManipulator(new NodeDragger());
    }
}

GraphEditorElementも伴って変更します。

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    GraphAsset m_GraphAsset;  // 渡されたアセットを保存
    List<NodeElement> m_Nodes;  // 作ったノードを入れておく。順序が重要

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる
        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }
    }

    void CreateNodeElement(SerializableNode node)
    {
        var nodeElement = new NodeElement(node);

        Add(nodeElement);  // GraphEditorElementの子として追加
        m_Nodes.Add(nodeElement);  // 順番を保持するためのリストに追加
    }

/* ... 省略 */

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;

        CreateNodeElement(new SerializableNode() { position = mousePosition });  // 追加生成時には仮で新しく作る
    }
}

GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です

// GraphEditorクラス
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement(graphAsset);  // アセットを渡す
        root.Add(m_GraphEditorElement);
    }

以上で、アセットに保持された情報を描画することができました。
NodeEditor-19.gif
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。

4.5 追加作成したノードをアセットに書き込む

前節までできたら、あとはもう少し変えるだけです。

// GraphEditorElementクラス
    private void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;
        var node = new SerializableNode() { position = mousePosition };

        m_GraphAsset.nodes.Add(node);  // アセットに追加する

        CreateNodeElement(node);
    }

これでアセットに書き込まれます。
NodeEditor-20.gif
おっと、動かしたことを記録するのを忘れていました。

// NodeDraggerクラス
    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();

            if(target is NodeElement node)
            {
                //NodeElementに保存しておいたシリアライズ対象のポジションをいじる
                node.serializableNode.position = target.transform.position;
            }

            m_Focus = false;
        }
    }

動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
NodeEditor-21.gif

5. エッジを追加する

頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw系の関数が一番楽かなと思います。
DrawLineDrawBezierなどです。

ちなみにGraphViewでは、エッジ用のメッシュを作って、Graphics.DrawMeshNow()で描画をしていました。

5.1 エッジを表示する

とりあえずダミーでデータを作ってみます。
NodeEditor-22.PNG
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
NodeEditor-23.PNG
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。

VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、Handlesで描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUIです。
ウィンドウの更新のたびにOnGUIが呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。

ひとまずこのように実装してみます。

// GraphEditorクラス
    private void OnGUI()
    {
        if(m_GraphEditorElement == null)
        {
            return;
        }

        m_GraphEditorElement.DrawEdge();
    }
// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                DrawEdge(
                    startPos: m_Nodes[i].transform.position,
                    startNorm: new Vector2(0f, 1f),
                    endPos: m_Nodes[edge.toId].transform.position,
                    endNorm: new Vector2(0f, -1f));
            }
        }
    }

    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;  // 色指定

        // エッジをベジェ曲線で描画
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        // 矢印の三角形の描画
        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);

        Handles.color = Color.white;  // 色指定をデフォルトに戻す
    }

このようになりました。
NodeEditor-24.gif

ポジションとして単にVisualElement.transform.positionを利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。

// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                // ノードに情報を問い合わせる
                DrawEdge(
                    startPos: m_Nodes[i].GetStartPosition(),
                    startNorm: m_Nodes[i].GetStartNorm(),
                    endPos: m_Nodes[edge.toId].GetEndPosition(),
                    endNorm: m_Nodes[edge.toId].GetEndNorm());
            }
        }
    }
// NodeElementクラス

    public Vector2 GetStartPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value);
    }
    public Vector2 GetEndPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f);
    }
    public Vector2 GetStartNorm()
    {
        return new Vector2(0f, 1f);
    }
    public Vector2 GetEndNorm()
    {
        return new Vector2(0f, -1f);
    }

ちゃんとそれらしい位置から生えました。
NodeEditor-25.gif

また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。

そこで、エッジにもEdgeElementクラスを作ります。

// EdgeElement.cs

using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor;

public class EdgeElement : VisualElement
{
    public SerializableEdge serializableEdge;  // データを持っておく

    public NodeElement From { get; private set; }  // 元ノード
    public NodeElement To { get; private set; }  // 先ノード

    public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to )
    {
        serializableEdge = edge;
        From = from;
        To = to;
    }

    public void DrawEdge()
    {
        if(From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
    }

    // GraphEditorElementからそのまま移した
    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);
        Handles.color = Color.white;
    }
}

このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。

実装はこうです。

// GraphEditorElementクラス

    List<EdgeElement> m_Edges;  // エッジもノードと同じくまとめて保持しておく

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }

        // すべてのノードの生成が終わってからエッジの生成を行う
        // エッジが持っているノードIDからノードを取得するため
        m_Edges = new List<EdgeElement>();

        foreach(var node in m_Nodes)
        {
            foreach(var edge in node.serializableNode.edges)
            {
                CreateEdgeElement(edge, node, m_Nodes);
            }
        }
    }

    // エッジの生成
    public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements)
    {
        var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった
    public void DrawEdge()
    {
        foreach(var edge in m_Edges)
        {
            edge.DrawEdge();
        }
    }

見た目は先ほどと変わりません。

5.2 エッジを追加できるようにする

あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。

UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
NodeEditor-26.gif

UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
NodeEditor-27.gif

これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。

挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定する

を想定します。
ノードに対する操作なので、ノードにManipulatorを追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。

5.2.1 EdgeConnectorクラスを作る

EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulatorの役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulatorを作成し、それをEdgeConnectorのターゲットノードにAddManipulatorしようと思います。

こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。

// NodeElementクラス
    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());  // 追加
    }

そして、EdgeConnectorの内部はひとまずこのようにしておきます。

using UnityEngine;
using UnityEngine.UIElements;

public class EdgeConnector : MouseManipulator
{
    bool m_Active = false;

    ContextualMenuManipulator m_AddEdgeMenu;

    public EdgeConnector()
    {
        // ノードの接続は左クリックで行う
        activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse });

        m_Active = false;

        // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、
        // RegisterCallbacksOnTarget()で追加する
        m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate);
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る
            if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition)))
            {
                // イベントを即座に中断
                evt.StopImmediatePropagation();
                return;
            }

            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    Debug.Log("Add Edge");  // ここでエッジ追加モード開始処理を書く

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    protected override void RegisterCallbacksOnTarget()
    {
        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut);

        target.AddManipulator(m_AddEdgeMenu);
    }

    protected override void UnregisterCallbacksFromTarget()
    {
        target.RemoveManipulator(m_AddEdgeMenu);

        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        if (!CanStartManipulation(evt))
            return;

        // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する
        if (m_Active)
            evt.StopImmediatePropagation();
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        Debug.Log("Try Connect");  // ここでマウスの下にあるノードにエッジを接続しようとする

        m_Active = false;
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
            return;

        Debug.Log("move");  // ここで、追加中のエッジの再描画を行う
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        m_Active = false;
        target.ReleaseMouse();
    }
}

この時点では、以下のような挙動になります。
NodeEditor-28.gif

5.2.2 エッジ追加のためにエッジ・グラフクラスを整備

次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。

// EdgeElementクラス

    Vector2 m_ToPosition;
    public Vector2 ToPosition
    {
        get { return m_ToPosition; }
        set
        {
            // 2020/01/09 追記:GraphEditorElementの座標系で渡されることを想定するように変更

            // m_ToPosition = this.WorldToLocal(value);  // ワールド座標で渡されることを想定
            //  ↓↓ 変更
            m_ConnectingEdge.ToPosition = m_Graph.WorldToLocal(evt.mousePosition);
            // 2020/01/09 追記ここまで

            MarkDirtyRepaint();  // 再描画をリクエスト
        }  
    }

    // 新しいコンストラクタ
    public EdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    // つなげるときに呼ぶ
    public void ConnectTo(NodeElement node)
    {
        To = node;
        MarkDirtyRepaint();  // 再描画をリクエスト
    }

    public void DrawEdge()
    {
        if (From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
        else {
            // 追加中の描画用
            if (From != null)
            {
                DrawEdge(
                    startPos: From.GetStartPosition(),
                    startNorm: From.GetStartNorm(),
                    endPos: ToPosition,
                    endNorm: Vector2.zero);
            }
        }
    }

これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。

// GraphEditorElementクラス
    public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        var edgeElement = new EdgeElement(fromNode, toPosition);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    public void RemoveEdgeElement(EdgeElement edge)
    {
        Remove(edge);
        m_Edges.Remove(edge);
    }

5.2.3 エッジ追加の挙動を実装

上で作った関数をEdgeConnectorクラスから呼びます。

// EdgeConnectorクラス

    GraphEditorElement m_Graph;
    EdgeElement m_ConnectingEdge;

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    // 親をたどってGraphEditorElementを取得する
                    m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>();
                    m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition);

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    /* ... 省略 */

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null  // 背景をクリックしたとき
            || node == target  // 自分自身をクリックしたとき
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))  // すでにつながっているノード同士をつなげようとしたとき
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
        }
        m_Active = false;
        m_ConnectingEdge = null;  // 接続終了
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
        {
            return;
        }

        // 2020/01/09 追記:Worldの座標系からGraphEditorElementの座標系に変換して渡すことにする
        //                 この例でいうと、ウィンドウの上のタブ領域の分だけずれることになる

        // m_ConnectingEdge.ToPosition = evt.originalMousePosition;  // 位置更新
        //  ↓↓ 変更
        m_ConnectingEdge.ToPosition = m_Graph.WorldToLocal(evt.mousePosition);  // 位置更新
        // 2020/01/09 追記ここまで
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        // 中断時の処理
        m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        m_ConnectingEdge = null;

        m_Active = false;
        target.ReleaseMouse();
    }
// GraphEditorElementクラス

    // マウスの位置にあるノードを返す
    public NodeElement GetDesignatedNode(Vector2 position)
    {
        foreach(NodeElement node in m_Nodes)
        {
            if (node.ContainsPoint(node.WorldToLocal(position)))
                return node;
        }

        return null;
    }

    // すでに同じエッジがあるかどうか
    public bool ContainsEdge(NodeElement from, NodeElement to)
    {
        return m_Edges.Exists(edge =>
        {
            return edge.From == from && edge.To == to;
        });
    }

ここまでで、このような挙動になります。
NodeEditor-29.gif

5.2.4 追加したエッジをシリアライズする

今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。

// EdgeConnectorクラス

    protected void OnMouseUp(MouseUpEvent evt)
    {
        /* ... 省略 */
        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null
            || node == target
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
            m_Graph.SerializeEdge(m_ConnectingEdge);  // つないだ時にシリアライズする
        }

        /* ... 省略 */
    }
// GraphEditorElementクラス

    public void SerializeEdge(EdgeElement edge)
    {
        var serializableEdge = new SerializableEdge()
        {
            toId = m_Nodes.IndexOf(edge.To)  // ここで先ノードのIDを数える
        };

        edge.From.serializableNode.edges.Add(serializableEdge);  // 実際に追加
        edge.serializableEdge = serializableEdge;  // EdgeElementに登録しておく
    }

そして、実際に開きなおしてみると、
NodeEditor-30.gif

保存されています。

5.3 エッジを削除できるようにする

エッジの追加ができるようになったので、やはり削除もできなければいけません。

ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElementだったため、ContextualManipulatorを付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElementは大きさがありません。

5.3.1 エッジを選択できるようにする

VisualElementをクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。

You can override the VisualElement.ContainsPoint() method to perform custom intersection logic.

このVisualElement.ContainsPoint()は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElementRect以外の形に対応させることができます。

実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。

さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。

// EdgeElementクラス

    // 削除用マニピュレータの追加
    public EdgeElement()
    {
        // 2020/01/09 追記:マウスのイベントを呼ばれるためにはVisualElementのRectがマウス座標を含む必要がある
        //                 よって、エッジも大きさを持つため、自由な位置を取れるようにPosition.Absoluteを指定する
        style.position = Position.Absolute;
        // 2020/01/09 追記ここまで

        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    Debug.Log("Remove Edge");
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }

    public EdgeElement(NodeElement fromNode, Vector2 toPosition):this()  // 上のコンストラクタを呼ぶ
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this()  // 上のコンストラクタを呼ぶ
    {
        serializableEdge = edge;
        From = fromNode;
        To = toNode;
    }

まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。

// EdgeElementクラス

    // 2020/01/09 追記:バウンディングボックスの取得を別関数に分ける
    //                 また、幅が狭くなりすぎないように少し大きめに取ることにする
    //                 その大きさをDrawEdgeのタイミングで適用することにする
    private Rect GetBoundingBox()
    {
        Vector2 start = From.GetStartPosition();
        Vector2 end = To.GetEndPosition();

        Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x) - 12f, Mathf.Min(start.y, end.y) - 12f);
        Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x) + 24f, Mathf.Abs(start.y - end.y) + 24f);
        Rect bound = new Rect(rectPos, rectSize);

        return bound;
    }

    // 2020/01/09 追記
    private void UpdateLayout()
    {
        Rect bound = GetBoundingBox();

        // レイアウトをバウンディングボックスに合わせて調整
        style.left = bound.x;
        style.top = bound.y;
        style.right = float.NaN;
        style.bottom = float.NaN;
        style.width = bound.width;
        style.height = bound.height;
    }

    // 2020/01/09 追記
    public void DrawEdge()
    {
        if (From != null && To != null)
        {
            UpdateLayout();  // 見た目とずれることがないよう、ここでLayoutを修正する

            DrawEdge(/* ... 省略 */);
        }
        else
            /* ... 省略 */
    }
    // 2020/01/09 追記ここまで

    public override bool ContainsPoint(Vector2 localPoint)
    {
        if (From == null || To == null)
            return false;

        // 2020/01/09 追記:EdgeElementのレイアウトに大きさを持たせたことにより
        //                 base.ContainsPoint()がバウンディングボックスを見る処理として利用できるようになった

        /*
        Vector2 start = From.GetStartPosition();
        Vector2 end = To.GetEndPosition();

        // ノードを覆うRectを作成
        Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y));
        Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y));
        Rect bound = new Rect(rectPos, rectSize);

        if (!bound.Contains(localPoint))
        {
            return false;
        }
        */
        //  ↓↓ 変更
        if (!base.ContainsPoint(localPoint))
        {
            return false;
        }

        return true;
    }

結果はこうなりました。
NodeEditor-31.gif
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。

次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。

// EdgeElementクラス
    readonly float INTERCEPT_WIDHT = 15f;  // エッジと当たる距離

    public override bool ContainsPoint(Vector2 localPoint)
    {
        /* ... 省略 */

        if (!base.Contains(localPoint))
        {
            return false;
        }

        // 2020/01/09 追記:localPointはEdgeElementの座標系で与えられる
        //                 それをGraphEditorElementの座標系に合わせる必要がある
        localPoint = this.ChangeCoordinatesTo(parent, localPoint);
        // 2020/01/09 追記ここまで

        // 近似線分ab
        Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm();
        Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm();

        // 一致した場合はaからの距離
        if (a == b)
        {
            return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT;
        }

        // 直線abとlocalPointの距離
        float distance = Mathf.Abs(
            (b.y - a.y) * localPoint.x
            - (b.x - a.x) * localPoint.y
            + b.x * a.y - b.y * a.x
            ) / Vector2.Distance(a, b);

        return distance < INTERCEPT_WIDHT;
    }

結果はこうなりました。
NodeEditor-32.gif
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。

5.3.2 エッジデータを削除する

GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。

// EdgeElementクラス

    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    // 親をたどってGraphEditorElementに削除リクエストを送る
                    var graph = GetFirstAncestorOfType<GraphEditorElement>();
                    graph.RemoveEdgeElement(this);
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }
// GraphEditorElementクラス

    public void RemoveEdgeElement(EdgeElement edge)
    {
        // 消すエッジにSerializableEdgeがあれば、それを消す
        if(edge.serializableEdge != null)
        {
            edge.From.serializableNode.edges.Remove(edge.serializableEdge);
        }

        Remove(edge);
        m_Edges.Remove(edge);
    }

NodeEditor-33.gif

無事、削除できています。

6. ノードを削除する

最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおす

のすべてを行う必要があります。

// NodeElementクラス

    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());
        this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate));  // 削除用マニピュレータ
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement)
        {
            evt.menu.AppendAction(
                "Remove Node",
                RemoveNodeMenuAction,
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    private void RemoveNodeMenuAction(DropdownMenuAction menuAction)
    {
        // 親をたどって削除をリクエスト
        var graph = GetFirstAncestorOfType<GraphEditorElement>();
        graph.RemoveNodeElement(this);
    }
// GraphEditorElementクラス

    public void RemoveNodeElement(NodeElement node)
    {
        m_GraphAsset.nodes.Remove(node.serializableNode);  // アセットから削除

        int id = m_Nodes.IndexOf(node);

        // エッジの削除とID変更
        // m_Edgesに変更が伴うため、降順で行う
        for (int i = m_Edges.Count - 1; i >= 0; i--)
        {
            var edgeElement = m_Edges[i];
            var edge = edgeElement.serializableEdge;

            // 削除されるノードにつながるエッジを削除
            if (edgeElement.To == node || edgeElement.From == node)
            {
                RemoveEdgeElement(edgeElement);
                continue;
            }

            // 変更が生じるIDを持つエッジに対して、IDに修正を加える
            if (edge.toId > id)
                edge.toId--;
        }

        Remove(node);  // VisualElementの子としてのノードを削除
        m_Nodes.Remove(node);  // 順序を保持するためのリストから削除
    }

これでノードを削除できるようになりました。
NodeEditor-35.gif

ウィンドウを開きなおしてもちゃんと構造が保存されています。

結果

NodeEditor-36.gif
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。

おわりに

UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。

また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。

ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。

75
61
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
75
61