【Unity】ノード表現でロジックをアセットに! GraphViewの入門とツール活用
はじめに
この記事は Colorful Palette アドベントカレンダー 12/20 の記事です。
こんにちは、株式会社Colorful Paletteでクライアントエンジニアをしているトミーです。
今回はUnityで提供されているビジュアルツール機能の一つであるGraphViewについてご紹介します。開発作業中に、エンジニアだけでなくプランナーやレベルデザイナーも直感的にロジックを組み立てられ、アセットとしてアプリに配信できることを目指し、キャッチアップしました。
それをもとに、本記事で基本的な概要から活用のアイディアを紹介させていただきます。
概要
GraphViewはUIElement(UIToolkit)で提供されている機能の一つです。UIToolkitはエディタ拡張のためのUI フレームワークで、HTML/CSSに似たUXML/USSという形式でマークアップとスタイリングを構築することができ、 WebライクにUIを作ることができます。
GraphViewはこのフレームワークを活用して、ノードベースのツールを構築できるようにするためのAPIとコンポーネントを提供しています。
GraphViewの要素
GraphViewにおける要素は、大きくGraphView、Node、Port、Edgeの4つに分類されます。
GraphView
グラフの描画や操作を行う、エディタウィンドウの直下にある他の要素の親となるコンテナです。グラフのズームイン・アウトやドラッグなどの操作、Nodeの配置、移動など制御します。
Node
入力、出力のポートを持たせることができる、ノードベースツールの基本となる要素です。このノードの種類やつなぎ方の組み合わせでデータやロジックのプロセスを表現します。
Port
ノードの入出力を行う接続点であり、他のNodeとEdgeを介してつながります。型を指定することができ、実装によって接続できる型を制限することができます。
Edge
ノードを結ぶ線で、Portがどこに結ばれているかを視覚的に表現します。
これらの要素を組み合わせることで、GraphView内でNodeを配置し、PortをEdgeで接続して、ロジックを視覚的に表現することが可能です。
カスタムノード作成の基本
今回は主にノードに対する実装にフォーカスします。GraphViewの実装やNodeをビュー上へ生成する方法など、全体的な実装は以下の記事などをご参考ください。
UnityのノードベースUI構築におけるGraphViewの活用(CyberAgentゲーム事業部記事)
Nodeを継承した上で、継承元のメンバに対してカスタマイズしたい内容を実装することでカスタムノードを作ることができます。今回の場合は、タイトル名の設定、入力・出力ポートを生成した上でコンテナへ追加を行っています。
public class SampleNode : Node
{
public SampleNode()
{
this.title = "カスタムノード";
// 入力ポートの作成
Port inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(float));
inputPort.portName = "入力";
this.inputContainer.Add(inputPort);
// 出力ポートの作成
Port outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(float));
outputPort.portName = "出力";
this.outputContainer.Add(outputPort);
}
}
上記のように簡単にノードを表現することができました。
ノード同士のアクセスもやりやすく、Port経由で接続先のNodeのインスタンスを取得することも容易です。
// 自分の出力ポートに接続しているノード
Node nextNode = outputPort // Port
.connections // IEnumerable<Edge>
.FirstOrDefault()? // Edge
.input? // Port
.node; // Node
// 自分の入力ポートに接続しているノード
Node prevNode = inputPort // Port
.connections // IEnumerable<Edge>
.FirstOrDefault()? // Edge
.output? // Port
.node; // Node
また、ノード内に追加のパラメータとして入力フィールドやプルダウンメニューなどのUIを追加することも容易です。
public SampleNode()
{
// (省略)
TextField textField = CreateTextField("テキストフィールド", "初期値");
mainContainer.Add(textField);
PopupField<string> popupField = CreatePopupField(new List<string> {"選択肢1", "選択肢2", "選択肢3"});
mainContainer.Add(popupField);
}
private TextField CreateTextField(string label, string defaultValue)
{
TextField textField = new(label);
textField.SetValueWithoutNotify(defaultValue);
return textField;
}
private PopupField<string> CreatePopupField(List<string> options)
{
PopupField<string> popupField = new(
options,
0,
value => value,
value => value
);
popupField.SetValueWithoutNotify(options[0]);
return popupField;
}
上記の通りポートや他のUIElementの要素の追加がコードで簡単にできることは、UIElement(UIToolkit)で提供されている強力さを実感できます。
GraphViewのツール活用
GraphViewの基本要素やカスタム方法を紹介しましたが、ここからどのように活用するかは要件によって多様かと思います。
冒頭に記載した「エンジニアだけでなくプランナーやレベルデザイナーも直感的にロジックを組み立てられ、アセットとしてアプリに配信できること」を実現するには、以下のような実装が必要かと思います。
ビジュアルプログラミングを表現する各種ノードの実装
用意した各種ロジックフローを再現するための機能ノード群
値を設定したり保持する変数ノード
分岐やループを含む実行順序を制御するフロー制御ノード
状態や終了などを示す特別なノード
エディタツールとしての機能実装
ノードをGraphView上に生成するUI、ノード検索ウィンドウなど
編集データの保存、読み込み機能
ランタイム実行用データ出力
その他補助機能(例:実行順序の視覚化)
ランタイム実行のための設計・実装
UnityのGraphViewは、あくまでViewとしての機能を提供しています。そのため、接続情報を基にしたランタイム処理の具体的な方法は要件に応じて設計する必要があります。
上記挙げた項目に沿った実装検証では、GraphView上のノード配置・接続などの編集情報と、ランタイムで実行するための情報は別々に管理しました。
最終目標は、実行データをJsonやScritableObjectなどのフォーマットで出力し、アプリ更新を伴わずに配信できるような状態です。以下の例では、ノードの種類や接続情報が表現されており、事前にバイナリ側で用意したコードを元に順番に実行していくことが可能です(データ設計は仮です)
// 各ノード情報
"_Executes": [
// forループノード
{
"_NodeIndex": 0,
"_NodeTypeName": "ForLoopNode",
"_NextNodeIndex": -1,
"_Json": "",
"_InputPortReferences": [
{
"_PortIndex": 0,
"_OpponentNodeIndex": 1,
"_OpponentPortIndex": 0,
"_OpponentPortValueTypeName": "Port"
},
{
"_PortIndex": 1,
"_OpponentNodeIndex": 2,
"_OpponentPortIndex": 0,
"_OpponentPortValueTypeName": "Int32"
}
]
},
// ログ出力ノード
{
"_NodeIndex": 3,
"_NodeTypeName": "LogNode",
"_NextNodeIndex": -1,
"_Json": "{\"_text\":\"Hello World\"}",
"_InputPortReferences": [
{
"_PortIndex": 0,
"_OpponentNodeIndex": 0,
"_OpponentPortIndex": 1,
"_OpponentPortValueTypeName": "Port"
},
{
"_PortIndex": 1,
"_OpponentNodeIndex": 4,
"_OpponentPortIndex": 0,
"_OpponentPortValueTypeName": "String"
}
]
}
// (以下変数ノードなど、省略)
検証過程で、開発中のプロジェクトの特定の処理群をノード表現する試みも行いました。その中で難しいと感じたことは、ノードとポートをどのような粒度でまとめて表現するか、ということです。
処理がある程度まとまったノードは便利ですが、必ずしも再利用性が高いわけではありません。粒度が小さければ実際のコーディングのように自由な組み合わせが可能ですが、視覚的な複雑さやPort接続作業の手間がかかるなどのデメリットもあると感じました。
関数の引数をそのまま全てPortにするとそれだけ接続の手間がかかるので、解決策として、引数の数を減らすようなデータ設計や、ツール利用者が意識する必要のないシステム引数を隠す、利便性を高めるツール機能を提供するなど工夫が必要になります。
以下は、粒度・引数の数が大きい関数をそのままノード化して動作検証を行った様子ですが、愚直に行ったためノードは特化型となり汎用性が低く、多くのPortがあるため、上述の課題を軸に工夫が必要です。
おわりに
GraphViewの基本と、ツールとしての活用の構想を紹介させていただきました。GraphViewはカスタムノードを簡単に追加できる柔軟性があり、ノードベースのツールの開発過程がスムーズになります。
今回は実際の活用例について多く触れられませんでしたが、検証や設計・実装を続け、将来的に知見を共有できることを目指していきます。
この記事が何かの参考になっていただければ幸いです。