【Unity】【エディタ拡張】ノードエディタを作るGraph Viewの基本的な使い方

Unityでノードエディタを簡単に作れるGraph Viewの基本的な使い方についてまとめました。

Unity2019.4.0

作るもの

UnityのShader GraphやVFX Graphを使うと、以下のようにノードベースのエディタによりアセットが編集できます。

f:id:halya_11:20200613220643p:plain
Shader Graph

Unityが用意しているGraphViewとその周辺クラスを使うとこのようなノードエディタのViewを簡単に作ることができます。
GraphViewは現時点でExperimentalという位置付けで公開されていますが、
既にShader Graphなどに使われていることもあり普通に使えるのではないかと思います。

本記事ではこの機能を使って以下のような簡単なグラフを作ってみます。

f:id:halya_11:20200613220455p:plain
最終成果物

なおGraph ViewはUIElementsを使って作られています。
これに関しては以下の記事にまとめていますので必要に応じて参照してください。

light11.hatenadiary.com

ノードを作る

まず以下のようなノードを表すクラスを作ります。

f:id:halya_11:20200613221720p:plain
ノード

ノードはNodeクラスを継承することにより作成します。
また、ノードには入出力ポートが存在するのでコンストラクタでそれらを作成します。
今回は入力ポートと出力ポートを一つずつ作成しました。

using UnityEditor.Experimental.GraphView;

public class ExampleNode : Node
{
    public ExampleNode()
    {
        title = "Example";
        
        // 入力用のポートを作成
        var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(float)); // 第三引数をPort.Capacity.Multipleにすると複数のポートへの接続が可能になる
        inputPort.portName = "Input";
        inputContainer.Add(inputPort); // 入力用ポートはinputContainerに追加する
        
        // 出力用のポートを作る
        var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(float));
        outputPort.portName = "Value";
        outputContainer.Add(outputPort); // 出力用ポートはoutputContainerに追加する
    }
}

グラフを作る

次にこのポートを表示するためのグラフを作ります。

グラフはGraphViewを継承したクラスを作ることで作成できます。
とりあえず確認用に前節で作ったノードを一つAddElement()で子に追加しています。

またついでにサイズを親に合わせたり基本的なマウス操作ができるようにセットアップしています。

using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;

public class ExampleGraphView : GraphView
{
    public ExampleGraphView(EditorWindow editorWindow)
    {
        // ノードを追加
        AddElement(new ExampleNode());

        // 親のサイズに合わせてGraphViewのサイズを設定
        this.StretchToParentSize();

        // MMBスクロールでズームインアウトができるように
        SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
        // MMBドラッグで描画範囲を動かせるように
        this.AddManipulator(new ContentDragger());
        // LMBドラッグで選択した要素を動かせるように
        this.AddManipulator(new SelectionDragger());
        // LMBドラッグで範囲選択ができるように
        this.AddManipulator(new RectangleSelector());
    }
}

ウィンドウに表示する

さてそれではこれをウィンドウに表示してみます。
これは普通にEditorWindowを作って前節のGraphViewをrootVisualElementとして追加するだけです。

using UnityEditor;

public class ExampleGraphEditorWindow : EditorWindow
{
    [MenuItem("Window/ExampleGraphEditorWindow")]
    public static void Open()
    {
        GetWindow<ExampleGraphEditorWindow>(ObjectNames.NicifyVariableName(nameof(ExampleGraphEditorWindow)));
    }

    void OnEnable()
    {
        var graphView = new ExampleGraphView(this);
        rootVisualElement.Add(graphView);
    }
}

このウィンドウを開くと以下のようにノードが表示されます。
この時点でマウス操作も効くようになっています。

f:id:halya_11:20200613222108p:plain
ウィンドウ

他のノードを作る

さてノードの表示が確認できたところで今回必要なノードをいくつか作っていきます。

まずは一番簡単そうなOutputノードです。

f:id:halya_11:20200613223925p:plain
Outputノード

これはInputノードを一つ生成するだけです。

using UnityEditor.Experimental.GraphView;

public class OutputNode : Node
{
    public OutputNode()
    {
        title = "Output";
        var port = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(float));
        port.portName = "Value";
        inputContainer.Add(port);
    }
}

次にAddノードを作ります。

f:id:halya_11:20200613224044p:plain
Addノード

二つのInputノードと一つのOutputノードを定義します。

using UnityEditor.Experimental.GraphView;

public class AddNode : Node
{
    public AddNode()
    {
        title = "Add";
        
        var inputPort1 = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(float));
        inputPort1.portName = "A";
        inputContainer.Add(inputPort1);
        
        var inputPort2 = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(float));
        inputPort2.portName = "B";
        inputContainer.Add(inputPort2);
        
        var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(float));
        outputPort.portName = "Out";
        outputContainer.Add(outputPort);
    }
}

最後に値を設定できるフィールドを持つValueノードです。

f:id:halya_11:20200613224331p:plain
Valueノード

ポートの設定方法は他のノードを変わりませんが、
値を設定するためのフィールドを追加するためにextensionContainerFloatFieldを追加している点に注目してください。
またextensionContainerにUI要素を追加した場合にはRefreshExpandedState()を呼んで見た目に即時反映する必要があります。

using UnityEditor.Experimental.GraphView;
using UnityEditor.UIElements;

public class ValueNode : Node
{
    public ValueNode()
    {
        title = "Value";

        var port = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(float));
        port.portName = "Value";
        outputContainer.Add(port);

        extensionContainer.Add(new FloatField());
        RefreshExpandedState();
    }
}

ノードを右クリックメニューから作れるようにする

さて次にこれらのノードをグラフの右クリックメニューから作れるようにします。
右クリックメニューを作るにはISearchWindowProviderを実装したScriptableObjectを作成します。

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEditor.Experimental.GraphView;
using UnityEngine.UIElements;

public class SearchMenuWindowProvider : ScriptableObject, ISearchWindowProvider
{
    private ExampleGraphView _graphView;
    private EditorWindow _editorWindow;

    public void Initialize(ExampleGraphView graphView, EditorWindow editorWindow)
    {
        _graphView = graphView;
        _editorWindow = editorWindow;
    }

    List<SearchTreeEntry> ISearchWindowProvider.CreateSearchTree(SearchWindowContext context)
    {
        var entries = new List<SearchTreeEntry>();
        entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));
        
        // Exampleというグループを追加
        entries.Add(new SearchTreeGroupEntry(new GUIContent("Example")) { level = 1 });
        
        // Exampleグループの下に各ノードを作るためのメニューを追加
        entries.Add(new SearchTreeEntry(new GUIContent(nameof(ValueNode))) { level = 2, userData = typeof(ValueNode) });
        entries.Add(new SearchTreeEntry(new GUIContent(nameof(AddNode))) { level = 2, userData = typeof(AddNode) });
        entries.Add(new SearchTreeEntry(new GUIContent(nameof(OutputNode))) { level = 2, userData = typeof(OutputNode) });

        return entries;
    }

    bool ISearchWindowProvider.OnSelectEntry(SearchTreeEntry searchTreeEntry, SearchWindowContext context)
    {
        var type = searchTreeEntry.userData as Type;
        var node = Activator.CreateInstance(type) as Node;

        // マウスの位置にノードを追加
        var worldMousePosition = _editorWindow.rootVisualElement.ChangeCoordinatesTo(_editorWindow.rootVisualElement.parent, context.screenMousePosition - _editorWindow.position.position);
        var localMousePosition = _graphView.contentViewContainer.WorldToLocal(worldMousePosition);
        node.SetPosition(new Rect(localMousePosition, new Vector2(100, 100)));
        
        _graphView.AddElement(node);
        return true;
    }
}

次に、右クリックをした時にこのメニューが開くようにGraphViewに処理を追加します。

using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
using UnityEngine;

public class ExampleGraphView : GraphView
{
    public ExampleGraphView(EditorWindow editorWindow)
    {
        AddElement(new ValueNode());
        
        this.StretchToParentSize();
        
        SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
        this.AddManipulator(new ContentDragger());
        this.AddManipulator(new SelectionDragger());
        this.AddManipulator(new RectangleSelector());
        
        // 右クリックメニューを追加
        var menuWindowProvider = ScriptableObject.CreateInstance<SearchMenuWindowProvider>();
        menuWindowProvider.Initialize(this, editorWindow);
        nodeCreationRequest += context =>
        {
            SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), menuWindowProvider);
        };
    }
}

これでウィンドウの右クリックメニューからノードを作れるようになりました。

f:id:halya_11:20200613225425g:plain

ノード同士を繋げられるようにする

さてこのままではノードを繋げられないので繋げられるようにします。
これはGraphView.GetCompatiblePorts()をオーバーライドすることで行います。

対象のポートに接続可能なポートのリストを返却します。

using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
using UnityEngine;

public class ExampleGraphView : GraphView
{
    public ExampleGraphView(EditorWindow editorWindow)
    {
        AddElement(new ValueNode());
        
        this.StretchToParentSize();
        
        SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
        this.AddManipulator(new ContentDragger());
        this.AddManipulator(new SelectionDragger());
        this.AddManipulator(new RectangleSelector());
        
        var menuWindowProvider = ScriptableObject.CreateInstance<SearchMenuWindowProvider>();
        menuWindowProvider.Initialize(this, editorWindow);
        nodeCreationRequest += context =>
        {
            SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), menuWindowProvider);
        };
    }

    // GetCompatiblePortsをオーバーライドする
    public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
    {
        var compatiblePorts = new List<Port>();

        compatiblePorts.AddRange(ports.ToList().Where(port =>
        {
            // 同じノードには繋げない
            if (startPort.node == port.node)
                return false;
            
            // Input同士、Output同士は繋げない
            if (port.direction == startPort.direction)
                return false;

            // ポートの型が一致していない場合は繋げない
            if (port.portType != startPort.portType)
                return false;

            return true;
        }));

        return compatiblePorts;
    }
}

これでポート同士を接続できるようになりました。

f:id:halya_11:20200613230815g:plain
ポート同士を接続

関連

light11.hatenadiary.com