【Unity】【エディタ拡張】Node Graph Processorを使ってノードエディタを簡単に作る

UnityでNode Graph Processorを使って簡単にノードエディタを作る方法を紹介します。

Unity2019.4.0
Node Graph Processor 0.7.1

Node Graph Processor?

Node Graph ProcessorはUnityでノードエディタを簡単に作るためのOSSです。

github.com

UnityのGraph Viewを内部的に使っていて、グラフやノードをより簡単に作れるようになっています。
Graph Viewについては以下の記事にまとめていますので、必要に応じて参照してください。

light11.hatenadiary.com

またGraph Viewはあくまでノードエディタのビューを作るために提供されている機能ですが、
Node Graph Processorはグラフやノードの情報をシリアライズして永続化する機能なども備えています。

実際にノードエディタを作る際には必要だけどGraph Viewに無いような機能をうまく提供してくれるライブラリです。

基本的な使い方

まず基本的な使い方をまとめます。

Windowを作る

初めにグラフを表示するEditor Windowを作ります。

using System.IO;
using UnityEngine;
using UnityEditor;
using GraphProcessor;
using UnityEngine.Assertions;

public class ExampleGraphWindow : BaseGraphWindow
{
    protected override void InitializeWindow(BaseGraph graph)
    {
        Assert.IsNotNull(graph);
        
        // ウィンドウのタイトルを適当に設定
        var fileName = Path.GetFileNameWithoutExtension(AssetDatabase.GetAssetPath(graph));
        titleContent = new GUIContent(ObjectNames.NicifyVariableName(fileName));

        // グラフを編集するためのビューであるGraphViewを設定
        if (graphView == null)
        {
            graphView = new BaseGraphView(this);
        }
        rootView.Add(graphView);
    }
}

BaseGraphViewかそれを継承するクラスのインスタンスgraphViewに代入します。
この時点ではひとまずBaseGraphViewをそのまま使います。

グラフを作る

次にグラフを作成します。
これはBaseGraphを継承したScriptableObjectとして作成します。

using GraphProcessor;
using UnityEditor.Callbacks;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

// CreateメニューからScriptableObjectのアセットを作れるように
[CreateAssetMenu(menuName = "Example Graph")]
public class ExampleGraph : BaseGraph
{
#if UNITY_EDITOR
    // ダブルクリックでウィンドウが開かれるように
    [OnOpenAsset(0)]
    public static bool OnBaseGraphOpened(int instanceID, int line)
    {
        var asset = EditorUtility.InstanceIDToObject(instanceID) as ExampleGraph;

        if (asset == null) return false;
        
        var window = EditorWindow.GetWindow<ExampleGraphWindow>();
        window.InitializeGraph(asset);
        return true;
    }
#endif
}

今回は処理は変えず、ただ継承してコメント部分の処理を追加しただけです。

ノードをいくつか作る

次にノードをいくつか作ります。
まずは二つの入力ポートの結果を足し合わせて出力するAddノードを作ります。

using System;
using GraphProcessor;

[Serializable]
[NodeMenuItem("Custom/Add")] // 作成時のメニュー名
public class AddNode : BaseNode
{
    // 入力ポートを定義
    [Input(name = "A")]
    public float input1;
    // 2つ目の入力ポートを定義
    [Input(name = "B")]
    public float input2;

    // 出力ポートを定義
    [Output(name = "Out", allowMultiple = false)]
    public float output;

    public override string name => "Add";

    // 計算処理
    protected override void Process()
    {
        output = input1 + input2;
    }
}

ポートを作成するには上記のようにInputOutputアトリビュートを付けたフィールドを定義します。
また実際の計算処理はProcess()をオーバーライドして行います。

同じようにしてfloat値を定義するFloatノードを作ります。

using GraphProcessor;

[System.Serializable, NodeMenuItem("Primitives/Float")]
public class FloatNode : BaseNode
{
    [Output("Out")]
    public float      output;
    
    [Input("In")]
    public float      input;

    public override string name => "Float";

    protected override void Process() => output = input;
}

同様に、計算結果を格納するResultノードを作ります。

using System;
using GraphProcessor;

[Serializable]
[NodeMenuItem("Custom/Result")]
public class ResultNode : BaseNode
{
    [Input(name = "Result")]
    public float input;

    private float _result;
    public float Result => _result;

    public override string name => "Result";

    protected override void Process()
    {
        _result = input;
    }
}
動作確認

ここまでで一度動作確認してみます。

まずAssets > Create > Example GraphからグラフのScritableObjectアセットを生成します。
そしてこのアセットをダブルクリックしてWindowを開きます。

f:id:halya_11:20200617230532p:plain
空のウィンドウ

Windowが開いたら右クリック > Create Nodeメニューから各ノードが生成できることを確認してください。

f:id:halya_11:20200617230842p:plain
ノードを生成

グラフを使って処理する

次にこのグラフを使って実際に計算を行っていきます。

カスタムGraphViewを作る

まず今のままではFloatノードに値を入力できません。
そこでノードのカスタムViewを実装することで値入力用のフィールドを表示させます。

using UnityEditor.UIElements;
using UnityEngine.UIElements;
using GraphProcessor;

[NodeCustomEditor(typeof(FloatNode))]
public class FloatNodeView : BaseNodeView
{
    public override void Enable()
    {
        var floatNode = nodeTarget as FloatNode;

        DoubleField floatField = new DoubleField
        {
            value = floatNode.input
        };

        floatNode.onProcessed += () => floatField.value = floatNode.input;

        floatField.RegisterValueChangedCallback((v) => {
            owner.RegisterCompleteObjectUndo("Updated floatNode input");
            floatNode.input = (float)v.newValue;
        });

        controlsContainer.Add(floatField);
    }
}

これでFloatノードに入力フィールドが表示されました。

f:id:halya_11:20200617231030p:plain
入力フィールド表示

Resultノードを最初から表示&消せなくする

次に、Resultノードは必ず一つだけ存在するという仕様にするため、最初から表示して消せなくします。
まず最初から表示するためにExampleGraphOnEnable()に処理を追加します。

using System.Linq;
using GraphProcessor;
using UnityEditor.Callbacks;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[CreateAssetMenu(menuName = "Example Graph")]
public class ExampleGraph : BaseGraph
{
    // 追加
    protected override void OnEnable()
    {
        base.OnEnable();
        
        // ResultNodeが無かったらつくる
        if (!nodes.Any(x => x is ResultNode))
        {
            AddNode(BaseNode.CreateFromType<ResultNode>(Vector2.zero));
        }
    }

    ...(略)...
}

また消せなくするにはGraphViewを拡張する必要があります。
以下のようにExampleGraphViewを作成します。

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

public class ExampleGraphView : BaseGraphView
{
    public ExampleGraphView(EditorWindow window) : base(window)
    {
    }

    public override IEnumerable<KeyValuePair<string, Type>> FilterCreateNodeMenuEntries()
    {
        foreach (var nodeMenuItem in NodeProvider.GetNodeMenuEntries())
        {
            // ResultNodeを追加できないように
            if (nodeMenuItem.Value == typeof(ResultNode))
            {
                continue;
            }
            yield return nodeMenuItem;
        }
    }

    protected override bool canDeleteSelection
    {
        // ResultNodeを消せないように
        get { return !selection.Any(e => e is ResultNodeView); }
    }
}

またExampleGraphWindowでこのExampleGraphViewを読み込むように変更します。

using System.IO;
using UnityEngine;
using UnityEditor;
using GraphProcessor;
using UnityEngine.Assertions;

public class ExampleGraphWindow : BaseGraphWindow
{
    protected override void InitializeWindow(BaseGraph graph)
    {
        Assert.IsNotNull(graph);
        
        var fileName = Path.GetFileNameWithoutExtension(AssetDatabase.GetAssetPath(graph));
        titleContent = new GUIContent(ObjectNames.NicifyVariableName(fileName));

        if (graphView == null)
        {
            // ExampleGraphViewに変更する
            graphView = new ExampleGraphView(this);
        }
        rootView.Add(graphView);
    }
}

これでResultノードが常に表示されるようになりました。

追記:2021/1/26

この節の内容はv1.0.0だと仕様が変わりコンパイルエラーとなることをご報告いただきました。
v1.0.0以降をお使いの方は適したAPIに適宜書き換えをお願いします。

処理を実行する

さてそれではグラフの処理を実行します。
処理を実行するにはBaseGraphProcessorを継承したクラスを作成します。

using System.Collections.Generic;
using System.Linq;
using Unity.Jobs;

namespace GraphProcessor
{
    public class ExampleGraphProcessor : BaseGraphProcessor
    {
        private List<BaseNode> _processList;
        public float Result { get; private set; }

        public ExampleGraphProcessor(BaseGraph graph) : base(graph)
        {
        }

        public override void UpdateComputeOrder()
        {
            _processList = graph.nodes.OrderBy(n => n.computeOrder).ToList();
        }
        
        public override void Run()
        {
            var count = _processList.Count;

            // すべてのノードを順番に処理する
            for (var i = 0; i < count; i++)
            {
                _processList[i].OnProcess();
            }

            JobHandle.ScheduleBatchedJobs();

            // Resultノードを取得する
            var resultNode = _processList.OfType<ResultNode>().FirstOrDefault();
            Result = resultNode.Result;
        }
    }
}

Run()の中ですべてのノードを処理し、その後に結果をResultに格納しています。

後はこれを実行するだけです。
今回はExampleGraphのInspectorに実行ボタンを表示してみました。

using GraphProcessor;
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(ExampleGraph))]
public class ExampleGraphEditor : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
        if (GUILayout.Button("Process"))
        {
            var graph = target as ExampleGraph;
            var processor = new ExampleGraphProcessor(graph);
            processor.Run();
            Debug.Log(processor.Result);
        }
    }
}

適当なグラフを作り実行ボタンを押すと正常に結果がログ出力することが確認できます。

f:id:halya_11:20200617235232p:plain
実行結果

その他

最後にその他の機能を簡単に紹介して終わります。

色んなノード

まずノードについては今回紹介したほかにも様々なものを作ることができます。

f:id:halya_11:20200616101133p:plain
色んなノード

一番右はわかりづいですが、チェックボックスにチェックを入れるとInputポートの型が変わります。

Conditional Processor

Conditional Processorを使うとノードをステップ実行することができます。

f:id:halya_11:20200618000354g:plain
ステップ実行

Exposed Property

Exposed Propertyを使うと変数を使えます。
定義した変数はInspectorから編集することも可能です。

f:id:halya_11:20200618001026g:plain
Exposed Property

関連

light11.hatenadiary.com

参考

github.com