UnityのノードベースUI構築におけるGraphViewの活用

こんにちは。
サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する株式会社QualiArtsでUnityエンジニアをしている住田です。本日から「QualiArts Tech Note」と題してQualiArtsエンジニアが技術記事を毎月上げていきます。本記事はその一つ目の記事となります。QualiArtsで使われている様々な技術を紹介していきますので、チェックしていただけたら嬉しいです。

さて、皆さまはUnityのエディタ拡張をどのように活用していますでしょうか。エディタ拡張はエンジニアの開発効率を上げるだけではなく、エンジニア以外の職種の生産性を上げることにも役立ちます。3Dモデルなどのアセットをクリエイターがインポートおよび確認するための補助ツール、企画職がコードを書かずともイベントやシナリオを実装することのできる独自エディタなど、エディタ拡張は様々な用途で開発の手助けに非常に有効です。そして、そういったクリエイターや企画職向けのエディタ拡張を行う際には、ノードベースのエディタが役に立ちます。

ノードベースを簡単にまとめると、一つ一つの処理をノードというGUIで捉え、つなぎ合わせることで任意の操作を行うというものです。ちょっと言葉だけだと説明が難しいので、下のgifを見てみてください。

このgifはUnityの提供するノードベースの機能であるShader Graphを使っている様子です。シェーダーに必要なパラメータやロジックをノード同士をつなげて設定しているのが分かるかと思います。UnityではShader Graphの他にもVFX Graphというノードベースでエフェクトが作成できる新機能をリリースしています。ノードベースのエディタはロジックをGUIで直感的に捉えることが可能で、MayaやBlender、HoudiniといったDCCツールにノードベースのものも多いことからクリエイターとの親和性も高く、開発全体の効率化を行うツールを作る上では有用な選択肢の一つです。

そこで、本記事ではUnityでノードベースのエディタ拡張を行う上で便利なGraphViewというUI Elements製の機能について解説します。使用しているUnityのバージョンは2019.3.7f1です。バージョンによっては本記事とは異なる挙動をする場合がありますので、ご注意ください。
※UI ElementsはUnity2020でUI Toolkitと改名されることが検討されています。Unity2020以降を使用されていたり、使う予定のある方はご注意ください。

UI Elementsとは

UI ElementsはUnityで最近新しくリリースされたUIのフレームワークです。従来Unityでエディタ拡張を行う際にはIMGUIと呼ばれるフレームワークが利用されていましたが、それをより良い形へ、と作られたのがUI Elementsになります。Unity2019.2まではpreviewという形でしたが、Unity2019.3でついに正式リリースとなりました。IMGUIと比較した際にも様々な利点が存在しており、公式では将来的にはランタイムでの実装にも活用するという構想も発表されている要注目なフレームワークです。UI ElementsにはWebライクな設計とUI構造によってロジックとデザインの実装が分離できたり、UI Elements Debuggerと言う専用の機能を使うことで構造の把握がしやすくなっています。GraphViewという機能を説明していく上では、しばしばUI Elementsの構造やその機能に基づいた説明をしていきます。UI Elementsについてまず知りたいという方は、筆者が参考にしたリンクを下に記述しているので、よければ参考にしてみてください。

▼参考リンク
Unity 2019.1 の UIElements の新機能 – Unity Technologies

【Unity】UIElements入門 – 概念~基本的な使い方まとめ

GraphViewによるノードベースの実装の手引き

GraphViewとは

UI Elementsでノードベースのエディタ拡張をする上でとても便利なのがこのGraphViewという機能です。ブログを執筆している2020年6月時点では正式なリリースとまではいっていませんが、ある程度動く形でUI Elementsの機能の一つとして提供されています。

上の画像はGraphViewを使ったUIの一例です。GraphViewを用いることで、このようなノードベースのUIを低コストで量産することができます。このGraphViewを活用した実装とはどのようなものなのか、主要な機能の紹介や実装の流れを踏まえてここから解説していきます。

GraphViewによる実装の手引き

GraphViewにはノードやノード同士をつなぐ辺など、UIを構成するためのパーツごとの役割のクラスがあり、それらの組み合わせを意識して実装していきます。

ノードを配置する領域の作成

まずノードを配置するための領域となるVisualElementを用意する必要があります。その役割を持つのがGraphViewというクラスです。GraphViewはノードベースのUI全体を管理する一つの大きなViewとしての役割を果たします。UI全体を構成するVisualTreeの大元の親となるクラスであり、子にノードとなるVisualElementを配置します。

次のコードはGraphViewの実装サンプルです。

public class SampleGraphView : GraphView
{
    public SampleGraphView() : base()
   {
       // 親のUIにしたがって拡大縮小を行う設定
       style.flexGrow = 1;
       style.flexShrink = 1;

       // ズーム倍率の上限設定
        SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
        // 背景の設定
        Insert(0, new GridBackground());
        // UIElements上でのドラッグ操作などの検知
        this.AddManipulator(new SelectionDragger());
        // イベントの設定、中身は後述
        SetEvents();
    }
}

ノードを配置する領域の拡大縮小の閾値など、全体の見た目に関する実装を行なっています。
GraphViewというクラス自体はabstractで機能が一式提供されている状態になります。なので、GraphViewを活用した実装を行う際には、このように継承したクラスを用意し、用意されている機能にしたがってロジックを書いていきます。もちろんGraphView自体もVisualElementなので、見た目の設定はUSSを用いてインポートすることも可能です。そして、実装するGraphViewは当然UIとして表示させる必要があります。表示するには大枠のウィンドウとなるEditorWindowを実装し、子の要素としてGraphViewを継承したクラスを配置しましょう。

次のコードがGraphViewを子に配置したEditorWindowの実装のサンプルになります。ウィンドウをメニューから開く実装と、GraphViewを子に配置する実装を加えています。

// GraphViewを子に持つEditorWindowのサンプル
public class SampleGraphWindow : EditorWindow
{
    // 子のGraphView
    private SampleGraphView _graph;

    // Unity上のメニューにSample→Open Windowという項目を追加して開く
    [MenuItem("Sample/Open Window")]
    public static void Open()
    {
        var graphEditor = CreateInstance();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("サンプル");
    }

    private void OnEnable()
    {
        CreateGraphView();
    }

    private void CreateGraphView()
    {
        _graph = new SampleGraphView();
        // 子の要素の追加、ノードを配置する土台となるGraphViewを配置する
        rootVisualElement.Add(_graph);
    }
}

rootVisualElementはEditorWindow上の大元となるVisualElementです。追加したメニューを選択するとウィンドウが開き、まっさらな次の画像のような画面が開きます。

まだ肝心のノードがないので分かりづらいですが、UI Elements Debuggerで確認するとGraphViewがVisualTreeの要素として存在していることがわかります。以上がノードを表示する領域の準備となるGraphViewの実装になります。最低限UIの要素として追加して表示するまで工程を説明しましたが、継承して実装するだけでシンプルなのが伝わったかと思います。

GraphViewにノードを追加する

次にGraphView上にノードを追加するための導線の実装について説明します。エディタ拡張としては、ウィンドウ上のUI操作でノードを追加したいところです。そこで、本記事ではGraphViewの機能を活用して、右クリックからメニューを開いてノードを追加するまでの流れを紹介します。GraphView上で右クリックをしてみると、次の画像のようなメニューが表示されます。これがあらかじめGraphViewで用意されている機能になります。


このメニューはContextualMenuPopulateEventというイベントと、それに付随するBuildContextualMenuというメソッドで制御されています。次のコードは実際のGraphView内のBuildContextualMenuの実装です。デフォルトでは切り取りやコピペといった基本的な操作のメニューが予め実装されています。

// UnityのGraphView.csの実装の一部
public virtual void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
     if (evt.target is UnityEditor.Experimental.GraphView.GraphView && this.nodeCreationRequest != null)
     {
         evt.menu.AppendAction("Create Node", new Action(this.OnContextMenuNodeCreate), 
             new Func<DropdownMenuAction, DropdownMenuAction.Status>(DropdownMenuAction.AlwaysEnabled), (object) null);
         evt.menu.AppendSeparator((string) null);
     }
     if (evt.target is UnityEditor.Experimental.GraphView.GraphView || evt.target is Node || evt.target is Group)
         evt.menu.AppendAction("Cut", 
            (Action) (a => this.CutSelectionCallback()), 
            (Func<DropdownMenuAction, DropdownMenuAction.Status>) (a => this.canCutSelection ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled), 
            (object) null);
     if (evt.target is UnityEditor.Experimental.GraphView.GraphView || evt.target is Node || evt.target is Group)
         evt.menu.AppendAction("Copy", 
        (Action) (a => this.CopySelectionCallback()), 
            (Func<DropdownMenuAction, DropdownMenuAction.Status>) (a => this.canCopySelection ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled), 
        (object) null);
     if (evt.target is UnityEditor.Experimental.GraphView.GraphView)
         evt.menu.AppendAction("Paste", 
            (Action) (a => this.PasteCallback()), 
            (Func<DropdownMenuAction, DropdownMenuAction.Status>) (a => this.canPaste ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled), 
            (object) null);
     if (evt.target is UnityEditor.Experimental.GraphView.GraphView || evt.target is Node || evt.target is Group || evt.target is Edge)
     {
         evt.menu.AppendSeparator((string) null);
         evt.menu.AppendAction("Delete", 
            (Action) (a => this.DeleteSelectionCallback(UnityEditor.Experimental.GraphView.GraphView.AskUser.DontAskUser)), 
            (Func<DropdownMenuAction, DropdownMenuAction.Status>) (a => this.canDeleteSelection ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled), 
            (object) null);
     }
     if (!(evt.target is UnityEditor.Experimental.GraphView.GraphView) && !(evt.target is Node) && !(evt.target is Group))
         return;
     evt.menu.AppendSeparator((string) null);
     evt.menu.AppendAction("Duplicate", 
        (Action) (a => this.DuplicateSelectionCallback()), 
        (Func<DropdownMenuAction, DropdownMenuAction.Status>) (a => this.canDuplicateSelection ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled), 
        (object) null);
     evt.menu.AppendSeparator((string) null);
}

virtualで実装されており、ロジックをGraphViewを継承したクラスであるBuildContextualMenuで紐付けてメニュー操作に連動させることができます。次のコードはノードを追加するロジックをメニューの追加の所に紐づける処理です。ここで型指定しているNodeについては後述しますが、ノードを表すクラスになります。

private void SetEvents()
{
    // SampleGraphViewのメニュー周りのイベントを設定する処理
    nodeCreationRequest += context =>
    {
        // 後述するNodeというクラスのインスタンス生成
        var node = new Node();
        // GraphViewの子要素として追加する
        AddElement(node);
    };
}

nodeCreationRequestは前述のBuildContextualMenuの内部でノードの生成のメニューを選択したときに呼び出されるActionになります。ここでは単純にノードのインスタンスを一つ追加し、GraphViewの子として追加する処理をActionに登録しています。実際にエディタでメニューを選択してみるとノードが生成されるのが確認できます。
次の画像は実装した操作によって生成されるノードです。

ノードの実装

ノードを生成するまでの過程を説明したので、次にノード自体の実装を説明していきます。
用いるのはノードの追加の説明でも少し言及したNodeというクラスです。GraphElementというGraphView上で扱うためのVisualElementのラッパークラスをベースにしており、ノードとしてのUIと機能を果たすための実装がされています。GraphViewの実装と同じように、Nodeクラスを継承してUIやロジックなどを実装していくとやりやすいです。まずは簡単なUIを表示させるための実装をしてみます。次のコードはノードのタイトルと中身となるシンプルなテキスト領域のUIを一つだけ設定したものです。mainContainerはノードのVisualTree上でUIを配置する親になります。

public class SampleNode : Node
{
    public SampleNode()
    {
        title = "サンプル";

        var text = new TextField("テキスト");
        mainContainer.Add(text);
    }
}

そして、先ほどの実装のノードを実際に表示してみると次の画像のようになります。ヘッダーの部分にタイトルの文字列が並び、真ん中に要素としてテキストと入力された文字領域が配置されています。

このように、ノードの実装はNodeクラスの機能にしたがって要素の追加やレイアウトの調整を行います。レイアウトの調整を行う際、先ほどの実装でも使用していたmainContainerのようなUIを配置するための親がメンバとしていくつか用意されています。次のコードはNodeクラスのメンバとして用意されているUIを配置するVisualElementのメンバになります。

// Node.csの実装の一部
//中央の領域
public VisualElement mainContainer { get; private set; } 
//タイトルのヘッダ部分の領域
public VisualElement titleContainer { get; private set; } 
//ノード同士の接続部分の領域
public VisualElement inputContainer { get; private set; } 
//ノード同士の接続部分の領域
public VisualElement outputContainer { get; private set; } 
//タイトルのヘッダにあるボタンを配置する領域
public VisualElement titleButtonContainer { get; private set; } 

これらをうまく活用することで、用途に合わせたノードのUIの構成を行うことが可能です。UI Elements Debuggerを活用しつつ配置を調整するとより使いやすいと思います。

ノードのパラメータのようなUIであればmainContainer、後述するノード同士の接続周りのUIであればinputContainerやoutputContainer、ヘッダ周りのUIであればtitleContainerなど、機能に沿って用意されたレイアウト上で構築することができるので、とりあえず作って試すのが簡単です。UI Elements Debuggerを活用しつつ配置を調整するとより使いやすいと思います。NodeはUI Elementsということで、先ほどの実装から少し発展させてUXMLを使ってノードの見た目を作ってみたのが次のコードになります。

// ノードの実装のサンプル
public class SampleNode : Node
{
    protected virtual string Title => "title";

    public SampleNode()
    {
        title = "サンプル";

        // UXMLを読み込んでmainContainerの下に配置する 
        var visualTree = Resources.Load("parameterContainer");
        visualTree.CloneTree(mainContainer);
    }
}

スクリプトでTextFieldを生成してAddしていた部分がVisualTreeAssetというリソースの読み込みに置き換わっているのがわかるかと思います。そして次のコードが読み込むUXMLです。適当な名前と説明を入力する欄を設けただけのシンプルなものです。

<?xml version="1.0" encoding="utf-8"?>
<engine:UXML xmlns:engine="UnityEngine.UIElements" xmlns:ui="UnityEngine.UIElements" xmlns:editor="UnityEditor.UIElements" >
   <!-- 要素をまとめる親 -->
   <ui:VisualElement name="contentContainer">
       <engine:VisualElement name="parameterContainer">
           <!-- 「名前」という名目のテキスト入力領域 -->
           <engine:TextField name="name" label="名前"/>
       </engine:VisualElement>
       <engine:VisualElement name="explanationContainer">
           <!-- 「説明」という名目の複数行テキスト入力領域 -->
           <engine:Label name="explanationLabel" text="説明"/>
           <engine:TextField name="explanationField" multiline="true"/>
       </engine:VisualElement>
   </ui:VisualElement>
</engine:UXML>

これを表示させると次の画像のような見た目になります。ノードの大きさが子要素によってリサイズされ、UXMLで定義したパーツを配置したUIになっています。

このように、UXMLでUI構造をモジュール化してC#と分離できるのがUI Elementsの強みです。実際にノードの見た目を作成していく際には、このようにUXMLで構造を考えていくと改修もしやすいですし、使い回しもやりやすいです。
以上がノードの実装についての説明になります。

ノード同士の接続

ノードを実装したら、次にノードをつなげる仕組みが必要になってきます。GraphViewにはノード同士をつなぐPortという機能が備わっています。次の画像はPortの機能を使ってUI上で接続を行なっている例です。見てわかる通り、Portはノード同士を接続する端子のような役割を持つクラスです。

inputとoutput、入力と出力という端子の方向があり、端子同士を辺でつなぐことでノード同士の接続をします。NodeクラスにはinputContainerとoutputContainerが予めPortを配置する場所として用意されているので、実装するときにはこれらのVisualElementの子にすると使いやすいです。

Portには表示するラベルや入出力どちらなのかの設定、縦向きか横向きかを制御するOrientation、一本の接続か多数の接続かを設定するCapacityといったオプションが存在します。そして、これらのオプションも踏まえて、Port.CreateというメソッドでPortを生成します。次のコードはCreatePortを用いてノードにPortをUIの要素として追加する処理です。型指定をしていますが、これはPort同士でやり取りするデータの型の定義です。

// SampleNode.csでのPortの生成ロジック

private Port _inputPort;
private Port _outputPort;

public void Initialize()
{
    // 中略

    _inputPort = AddInputPort("入力");
    _outputPort = AddOutputPort("出力")
}

private Port AddInputPort(string name, Orientation orientation = Orientation.Horizontal, Port.Capacity capacity = Port.Capacity.Single)
{
    var port = CreatePort(name, Direction.Input, orientation, capacity);
    inputContainer.Add(port);
    return port;
}

private Port AddOutputPort(string name, Orientation orientation = Orientation.Horizontal, Port.Capacity capacity = Port.Capacity.Single)
{
    var port = CreatePort(name, Direction.Output, orientation, capacity);
    outputContainer.Add(port);
    return port;
}

private Port CreatePort(string name, Direction direction, Orientation orientation = Orientation.Horizontal, Port.Capacity capacity = Port.Capacity.Single)
{
    var port = Port.Create(orientation, direction, capacity, typeof(T));
    port.portName = name;
    return port;
}  

ノード同士を繋げるということで、当然ノード同士でデータをやり取りしたいことがあると思います。そういったときにPortのメンバを介してやり取りを行うことができます。
次のコードはPortの実装からsourceというプロパティを抜粋したものです。Portは接続しているもう一方のPortへの参照が可能なので、このsourceを介してパラメータの受け渡しが可能です。

// Port.csの実装の一部
public object source { get; set; }

Port同士のデータの入出力や接続情報のやり取りについて、詳しくは後述のEdgeというクラスの説明で言及します。そしてPortを配置する実装を反映したノードが次の画像です。ヘッダの下の部分にPortが挿入されているのがわかるかと思います。 Portは追加して配置さえすればUIの操作が可能です。Portからドラッグを始めると辺が伸びます。その辺を別のPortの上にドラッグすればつながりますし、つながっている辺をPortから外すようにドラッグすれば接続を解除できます。UIの機能については完成されているため、基本的にはPortの接続に基づくロジックを実装するだけでノード同士のやり取りを実現することができます。

以上がノード同士の接続を行うまでの説明になります。このノード同士のやり取りの実装について、次の章で説明していきます。

 

Portを介したノード同士のデータのやり取り

Portを使うことでノード同士を接続することができました。この接続している部分の情報を参照するには、Portのconnectionsというプロパティを利用します。次のコードはPortの実装からconnectionsの実装を抜粋したものです。connectionsからPortに繋がっている辺の情報をEdgeというクラスの集合として取得することができます。

// Port.csの実装の抜粋
public virtual IEnumerable connections { get { return (IEnumerable) this.m_Connections; } }

Edgeは名前の通り、Port同士をつなぐ辺を意味するクラスです。GraphViewが提供するVisualElementの一種で、inputとoutput、つまりつながれている入力と出力に対応するPortの情報を持っています。NodeがPortの参照を持ち、Portが対応するEdgeの参照を持っているという構造になるため、NodeからPort、PortからEdge、EdgeからPortというように参照を手繰っていくことで、接続先のNodeの参照ができます。

次のコードは出力側のPortにつながっているNodeを継承したクラス全てに何かしらの処理を行うサンプルです。connectionsからEdgeそれぞれの入力側につながっているNodeクラスに射影して処理をしています。

private Port _inputPort;
private Port _outputPort;

public void Initialize()
{
    // 中略

    _inputPort = AddInputPort("入力");
    _outputPort = AddOutputPort("出力")
}

// 中略

// Nodeを継承したクラスで、出力側のPortから出力先のNodeを参照するサンプル
public virtual void Execute()
{
    if(_outputPort?.connections == null || _outputPort?.connections.Any() == false)
    {
        return;
    }

    IEnumerable nextNodes = _outputPort.connections.Select(connect => connect.input?.node);

    if (nextNodes == null || nextNodes.Any() == false) 
    {
        return;
    }

    foreach(var nextNode in nextNodes)
    {
        // 出力側に繋がっているノードに対する処理
    }
}

このように、PortとEdgeを活用することでノード間のやり取りを行うことができます。
そして、次のコードはノードの入力側のPortに格納されているデータを参照し、何かしらの処理を行うサンプルです。

// Nodeを継承したクラスで、入力側のPortからパラメータを受け取って処理を行うサンプル
public void Execute()
{
    if(_inputPort?.connections == null || _inputPort?.connections.Any() == false)
    {
        return;
    }

    var prevNodeParameters = _inputPort.connections.Select(connect => connect.output?.source);

    if (prevNodeParameters == null || prevNodeParameters.Any() == false) 
    {
        return;
    }

    foreach(var parameter in prevNodeParameters)
    {
        // 各パラメータごとの処理
    }
}

ここで使用しているのはPortの説明部分で紹介したsourceです。Portのsourceに射影することで、ノードがPortを介して渡そうとしているデータを受け取ることができます。パラメータをノードが受け取って、それをもとに演算処理を行って結果を出力側に流す工程はノードベースのクリエイティブツールでよく見るものです。そういった類のツールを作る際には、このsourceを活用したパラメータの伝搬が大変便利です。

以上がPortを使ってノード同士のデータのやり取りを行う方法になります。これらのサンプルのように、Portを活用してノード同士の関係性を処理することが、GraphViewで実装するときの肝となります。GraphViewを活用する際には、ノード間の処理をどのように伝搬させるのか、Portの性質を考えて設計してみてください。

まとめ

GraphViewを活用してUI Elementsでノードベースのエディタ拡張を実装する術について、いくつかのクラスを踏まえて解説しました。色々な機能を解説しましたが、GraphViewの性質として、ノードベースのエディタ拡張を一から自作することなく、ある程度完成した物から継承する形で実装できるのが伝わったかと思います。GraphView、Node、Port、Edge、説明してきたクラスにはここでは紹介できなかった機能も多く存在します。ノードベースのエディタ拡張は難しそうという印象を持たれている方もいるかと思いますが、本記事が少しでも実装の手助けになれば幸いです。まだまだUI Elementsを本格導入している方は少数かなとは思いますが、可能性を感じる機能ですので、是非触ってみて欲しいです。

LINEで送る
Pocket

おすすめ記事