#はじめに
この記事はUnity #2 Advent Calendar 2019の6日目の記事です。
Unity2018辺りからExperimentalで入ったGraphViewについて解説し、完全理解を目指します。
##自己紹介
現在、新卒入社したスマホゲーム会社で社会人4年目をやっています。
普段Unityを用いてゲーム開発をしつつ、全社で使用できるツールの作成なども行っています。
#GraphViewとは
現在Unityでは新しいエディタ用UI、UIElements(RMGUI)への移行が行われています。
UIElements自体は2019のバージョンからExperimentalが外れ、正式リリースされました。
GraphViewはUIElementsの一部として、ノードエディタを作成できる機能なのですが、現状ShaderGraph、VFXGraphなどのUnity公式のツールで使用されてはいますが、実験的機能という扱いで、まだ使うのは早い。という風な扱いです。
ただ、エディタ拡張好きとしては、抑えずにいられない機能なので、いったん2019年末の段階でのGraphViewを完全理解し、正式リリース時にすぐに使えるようになっておこうという試みです。
よって、正式リリース時にはAPI等の仕様が大きく変わっている可能性があります。
読み物程度に考えてもらえたらと思います。
##GraphViewの要素
GraphViewは大きく4つの要素から成っています。
- GraphView
- Node
- Port
- Edge
このほかにもBlackboardなどオプション的な要素はありますが、
今回は最小限のGraphView実装を行っていくので、一旦省きます。
上の要素の細かい説明は以下で実際に実装していく中でその都度行っていきます。
#実際にGraphViewを実装する
##環境
Unity2020.1.0a13
##1.EditorWindowを作成
まずは、エディタ拡張おなじみのEditorWindowを作ります。
using UnityEditor;
public class SampleGraphEditorWindow : EditorWindow
{
[MenuItem("Window/Open SampleGraphView")]
public static void Open()
{
GetWindow<SampleGraphEditorWindow>("SampleGraphView");
}
}
これでメニューのWindowから"Open SampleGraphView"を選択するとウインドウが表示されます。
##2.GraphViewを作成
次に、早速GraphViewを作成していきます。
これは上で示したNodeやEdgeの親となるものです。
一旦最小限で、何も表示されないGraphViewを作成します。
using UnityEditor.Experimental.GraphView;
public class SampleGraphView : GraphView
{
}
また、ここでEditorWindowにGraphViewを追加します。
void OnEnable()
{
rootVisualElement.Add(new SampleGraphView());
}
まだ何も表示されませんが、一旦これでOKです。
##3.Nodeを作成
GraphViewは一般的にノードエディタなどと呼ばれる物ですが、
Nodeは名前の通り、ノードエディタの一番重要と思われる部分になります。
一旦基底のNodeを作成し、実際に使う際にはそれを継承した色々な処理を持ったノードを作成していくことになるかと思います。
また、最小限に何も実装のないNodeを作成します。
using UnityEditor.Experimental.GraphView;
public class SampleNode : Node
{
}
これをGraphViewに追加します。一旦コンストラクタでやってしまいます。
public SampleGraphView() : base()
{
AddElement(new SampleNode());
}
で、エディタのウインドウの方を確認してみます。
すると、何も表示されていません…
こういう時に便利なのがUIElements Debuggerです。
どうやらNode自体は作成されているのですが、GraphViewのHeightが0になっているようです。
きちんと表示されるようにHeightを設定してあげます。
void OnEnable()
{
var graphView = new SampleGraphView()
{
style = { flexGrow = 1 }
};
rootVisualElement.Add(graphView);
}
これで、EditorWindowの高さに合わせて、GraphViewが伸び縮みして全体に表示されるようになり、隠れていたNodeも表示されるようになりました。
##4.NodeにPortをつける
Nodeは他のNodeとつなげて使います。
その際OutputPortからInputPortへEdgeをつなげます。
まずはNodeにInputPortとOutputPortをつけます。
ついでに簡単に見た目を整えます。
public SampleNode()
{
title = "Sample";
var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(Port));
inputContainer.Add(inputPort);
var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
outputContainer.Add(outputPort);
}
とりあえずPortとEdgeは基底の物を使いますが、なんか急にノードっぽくなりました。
##5.Nodeを動かせるようにする
こうなるとノードが動かないのがなんか違和感を感じる気がするので、いったんここで動かせるようにしておきます。
GraphViewでManipulatorというものを付けてあげると動くようになります。
Manipulatorにはいくつかあるのですが、今回はとりあえずドラッグして動かすためのものに限ります。
UnityEngine.UIElements
をusingしてあげて
using UnityEngine.UIElements;
SelectionDragger
をAddManipulator
すると
public SampleGraphView() : base()
{
AddElement(new SampleNode());
this.AddManipulator(new SelectionDragger());
}
ドラッグで移動できるようになりました。
##6.Nodeを複数作れるようにする
現状GraphViewのコンストラクタで1個作るようになっているので、
これを右クリックのメニューから追加できるようにします。
public SampleGraphView() : base()
{
this.AddManipulator(new SelectionDragger());
nodeCreationRequest += context =>
{
AddElement(new SampleNode());
};
}
これでSampleNodeが複数作れるようになりました。
こうなるとNodeを繋げたくなりますが、どうやらうまく繋がりません。
##7.Node同士を繋げる
NodeはどのNode、どのPortにでも繋がるわけではありません。
OutputPortから正しいInputPortにつなげてあげる必要があります。
そこでGetCompatiblePortsをoverrideして、正しいPortを返してあげます。
public override List<Port> GetCompatiblePorts(Port startAnchor, NodeAdapter nodeAdapter)
{
return ports.ToList();
}
今回はとりあえず全てのPortに繋がるようにしてみましたが、
本当はstartAnchorに入っているPortから正しく判断してあげる必要があります。
Edgeの色と背景の色が似ていてすごく見にくいですが、これでNode同士が繋がるようになりました。
ここで、GraphViewのUI自体はまあまあ抑えられたかと思います。
これで終わりにしようかとも思いましたが、実際にツールを作るとなるとまだ要素がたりてないと感じたので、
ツール作成の章に移ります。
#GraphViewを用いてツールを作成する
##要件
ツールと言っても、実用的なツールを作成するとなると流石に長くなりすぎると思うので、最小限の要件を定義します。
- ズームインズームアウトが行える
- 背景色を変更する
- 複数種のノードを作成できる
- 実行ボタンを押すとルートノードにつながっているノードの処理を行う
これに加えて機能ノードを作成しますが、今回はログ出力のノードのみ
ログ出力ノードには文字列の入力のみ
とします。
##1.ズームインズームアウトを行う
ノードエディタを使うとなると、流石にこれくらいは欲しいと思うので実装していきます。
実はGraphViewでSetupZoom
を呼び出すのみで、ズームインズームアウトを行うことができます。
public SampleGraphView() : base()
{
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
this.AddManipulator(new SelectionDragger());
nodeCreationRequest += context =>
{
AddElement(new SampleNode());
};
}
##2.背景色を変更する
こちらもシンプル、GraphViewにGridBackgroundというVisualElementを追加するととりあえず黒っぽい背景になるので、これでいきます。
public SampleGraphView() : base()
{
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
Insert(0, new GridBackground());
this.AddManipulator(new SelectionDragger());
nodeCreationRequest += context =>
{
AddElement(new SampleNode());
};
}
補足をすると、VisualElementというのはUIElementsの基本的な要素で、これを組み合わせることでUIが作られます。
少しUIElementsを触っていると、Add(VisualElement element)
の方を使えばいいのではないかと思うのですが、
そうするとGraphView内の要素よりも手前にGridBackgroundが表示されてしまうので、Insertを使って奥に差し込みます。
##3.ノードを実装する
とりあえずノードを作っていきます。
今回作成するノードは
- ルートノード
- ログ出力ノード
- 文字列出力ノード
一旦、今までのSampleNodeを抽象クラスとし、今回使用するノードはそれを継承するものとします。
using UnityEditor.Experimental.GraphView;
public abstract class SampleNode : Node
{
}
ではまずはログ出力ノードを作成します。
using UnityEditor.Experimental.GraphView;
public class ProcessNode : SampleNode
{
public ProcessNode()
{
var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(Port));
inputPort.portName = "In";
inputContainer.Add(inputPort);
var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
outputPort.portName = "Out";
outputContainer.Add(outputPort);
}
}
一旦、ProcessNodeをかませて、LogNodeを作成します。
using UnityEditor.Experimental.GraphView;
public class LogNode : ProcessNode
{
public LogNode() : base()
{
title = "Log";
var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(string));
inputContainer.Add(inputPort);
}
}
次に文字列出力ノードを作成します。これは同じ値をいろんなところに使いたいことが想定できるので、
CapacityをMultiにしておきます。
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
public class StringNode : SampleNode
{
private TextField textField;
public string Text { get { return textField.value; } }
public StringNode() : base()
{
title = "String";
var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(string));
outputContainer.Add(outputPort);
textField = new TextField();
mainContainer.Add(textField);
}
}
最後にルートノードを作成します。
ルートノードは消えないようにしておきたいので、capabilitiesからDeletableを引いておきます。
using UnityEditor.Experimental.GraphView;
public class RootNode : SampleNode
{
public RootNode() : base()
{
title = "Root";
capabilities -= Capabilities.Deletable;
var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
outputPort.portName = "Out";
outputContainer.Add(outputPort);
}
}
ここで、GraphViewを生成した際にRootNodeを1つ配置しておくようにします。
public RootNode root;
public SampleGraphView() : base()
{
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
Insert(0, new GridBackground());
root = new RootNode();
AddElement(root);
this.AddManipulator(new SelectionDragger());
nodeCreationRequest += context =>
{
AddElement(new SampleNode());
};
}
次にこれらの作成したノードをエディタ上から作成できるようにします。
##4.任意のノードを選択し、作成する
SearchWindowを用いると、ノードを選択できるようなUIを簡単に作成できます。
ISearchWindowProviderを実装したクラスがScriptableObjectが必要です。
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor.Experimental.GraphView;
public class SampleSearchWindowProvider : ScriptableObject, ISearchWindowProvider
{
private SampleGraphView graphView;
public void Initialize(SampleGraphView graphView)
{
this.graphView = graphView;
}
List<SearchTreeEntry> ISearchWindowProvider.CreateSearchTree(SearchWindowContext context)
{
var entries = new List<SearchTreeEntry>();
entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
if (type.IsClass && !type.IsAbstract && (type.IsSubclassOf(typeof(SampleNode)))
&& type != typeof(RootNode))
{
entries.Add(new SearchTreeEntry(new GUIContent(type.Name)) { level = 1, userData = type });
}
}
}
return entries;
}
bool ISearchWindowProvider.OnSelectEntry(SearchTreeEntry searchTreeEntry, SearchWindowContext context)
{
var type = searchTreeEntry.userData as System.Type;
var node = Activator.CreateInstance(type) as SampleNode;
graphView.AddElement(node);
return true;
}
}
CreateSearchTreeではSampleNodeクラスを継承したクラスをSearchTreeEntryに入れて返し、
OnSelectEntryで選択された項目に対応する処理を行います。
今回はここでNodeを作成しています。
また、右クリックでNodeを作成していたところを、SearchWindowを呼び出すように変更します。
public SampleGraphView() : base()
{
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
Insert(0, new GridBackground());
root = new RootNode();
AddElement(root);
this.AddManipulator(new SelectionDragger());
var searchWindowProvider = new SampleSearchWindowProvider();
searchWindowProvider.Initialize(this);
nodeCreationRequest += context =>
{
SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindowProvider);
};
}
これで任意のノードを選択して作成することができるようになりました。
##5.正しいポート同士のみが繋がるようにする
UI実装編でとりあえず全部のノードが繋がるので、そこを直していきます。
public override List<Port> GetCompatiblePorts(Port startAnchor, NodeAdapter nodeAdapter)
{
var compatiblePorts = new List<Port>();
foreach (var port in ports.ToList())
{
if (startAnchor.node == port.node ||
startAnchor.direction == port.direction ||
startAnchor.portType != port.portType)
{
continue;
}
compatiblePorts.Add(port);
}
return compatiblePorts;
}
とりあえず
- 同じノードに対してはつなげない
- InputからInput、OutputからOutputはつなげない
- Portに設定されているTypeが一致していないとつなげない
としました。実際にはもう少し考えることはあると思いますが、一旦これでいきます。
##6.実際にノードを用いて処理を行う
これで見た目上必要な部分の実装は完了したかと思います。
実際に処理を行うとなると、ルートノードにつながっているノードを順に取得し、処理を行う必要があります。
Nodeにつながっている別のNodeを取得する機能は無いようなので、Port生成時にキャッシュしておく必要がありそうです。
また、Node側で処理を記述していきたいので、ProcessNodeに処理用のメソッドを持たせてあげます。
using UnityEditor.Experimental.GraphView;
public abstract class ProcessNode : SampleNode
{
public Port InputPort;
public Port OutputPort;
public ProcessNode()
{
InputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(Port));
InputPort.portName = "In";
inputContainer.Add(InputPort);
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
OutputPort.portName = "Out";
outputContainer.Add(OutputPort);
}
public abstract void Execute();
}
using System.Linq;
using UnityEngine;
using UnityEditor.Experimental.GraphView;
public class LogNode : ProcessNode
{
private Port inputString;
public LogNode() : base()
{
title = "Log";
inputString = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(string));
inputContainer.Add(inputString);
}
public override void Execute()
{
var edge = inputString.connections.FirstOrDefault();
var node = edge.output.node as StringNode;
if (node == null) return;
Debug.Log(node.Text);
}
}
using UnityEditor.Experimental.GraphView;
public class RootNode : SampleNode
{
public Port OutputPort;
public RootNode() : base()
{
title = "Root";
capabilities -= Capabilities.Deletable;
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
OutputPort.portName = "Out";
outputContainer.Add(OutputPort);
}
}
これで、Rootから順にノードを取得していき、処理を行っていきます。
using System.Linq
-------------------------------------
public void Execute()
{
var rootEdge = root.OutputPort.connections.FirstOrDefault();
if (rootEdge == null) return;
var currentNode = rootEdge.input.node as ProcessNode;
while (true)
{
currentNode.Execute();
var edge = currentNode.OutputPort.connections.FirstOrDefault();
if (edge == null) break;
currentNode = edge.input.node as ProcessNode;
}
}
適当にこのメソッドを叩く口を作ってあげて
void OnEnable()
{
var graphView = new SampleGraphView()
{
style = { flexGrow = 1 }
};
rootVisualElement.Add(graphView);
rootVisualElement.Add(new Button(graphView.Execute) { text = "Execute" });
}
#まとめ
今回は基本的なUIの実装と、それを用いた簡単なツール作成を行いました。
個人的にAPIはだいぶ整っている感じがしたので、正式リリース時も、それほど変わっていないんじゃないかと思います。
実際にツールを作成するとなると、グラフの状態をファイルに書き出したり読み込んだり、
そのほかにも色々な要素が必要になるかとは思いますが、とっかかりとしてはこの程度かなと思います。
大したものではないですが、今回のプロジェクトもGitHubにあげてましたので、そちらも参照してください。
GitHub - ShunMc/SampleGraphView
#参考
GitHub - Unity-Technologies/ShaderGraph: Unity ShaderGraph project
GitHub - monry/UniFlow: Connect presentation events
GitHub - rygo6/GTLogicGraph: A generic graph made off the UIElement GraphView built into Unity.
Unity-Technologies / AssetBundleGraphTool / dev-2.0 — Bitbucket