📝

【Unity】GraphToolkitで実行時に入力値を生成する方法

に公開

はじめに

GraphToolkitは、Unity上でノードベースのツールを開発するためのフレームワークで、現在は実験的機能としてリリースされています。
このパッケージが提供するのはノードエディタのGUI機能のみで、ノードで構築したグラフをランタイムで実行可能なバックエンドに変換する処理は開発者が実装する必要があります。
公式からもいくつかサンプルが用意されており、例えばVisual Novel Directorではアセットインポートの段階でエディタのノードからランタイム用のノードに変換していますが、このアプローチだとインポートの段階で入力値が確定されてしまうという欠点があります。

本記事では公式サンプルのアーキテクチャをある程度踏襲しつつ、上記の欠点を回避し、ランタイムで動的に入力値を生成できるようなアーキテクチャを紹介します。

この記事で実現すること

最小構成の例として、以下の2つのノードを実装します:

  • RandomNumberNode: ランダムな整数値を生成するノード
  • DebugNode: 整数値を受け取ってログ出力するノード

これらを接続することで、実行のたびに異なるランダム値をログ出力できるグラフを構築します。

ディレクトリ構成

.
├── Runtime
│   ├── Variables
│   │   ├── IVariable.cs
│   │   ├── ConstantVariable.cs
│   │   └── RandomNumberVariable.cs
│   ├── IRuntimeNode.cs
│   ├── DebugRuntimeNode.cs
│   ├── SampleRuntimeGraph.cs
│   └── SampleRunner.cs
└── Editor
    ├── DebugNode.cs
    ├── ISampleNode.cs
    ├── ISampleVariableNode.cs
    ├── RandomNumberNode.cs
    ├── SampleGraph.cs
    └── SampleGraphImporter.cs
Runtime/Variables/IVariable.cs
namespace Forte.Runtime.Variables
{
    public interface IVariable<out T>
    {
        public T Value();
    }
}
Runtime/Variables/ConstantVariable.cs
using System;

namespace Forte.Runtime.Variables
{
    [Serializable]
    public class ConstantVariable<T> : IVariable<T>
    {
        public T value;

        public T Value()
        {
            return value;
        }
    }
}
Runtime/Variables/RandomNumberVariable.cs
using UnityEngine;

namespace Forte.Runtime.Variables
{
    public class RandomNumberVariable : IVariable<int>
    {
        public int Value()
        {
            return Random.Range(0, 100);
        }
    }
}
Runtime/IRuntimeNode.cs
namespace Forte.Runtime
{
    public interface IRuntimeNode
    {
        public void Execute();

        public interface IFactory
        {
            public IRuntimeNode Create();
        }
    }
}
Runtime/DebugRuntimeNode.cs
using System;
using Forte.Runtime.Variables;
using UnityEngine;

namespace Forte.Runtime
{
    [Serializable]
    public class DebugRuntimeNode : IRuntimeNode
    {
        public int number;

        public void Execute()
        {
            Debug.Log(number);
        }

        [Serializable]
        public class Factory : IRuntimeNode.IFactory
        {
            [SerializeReference]
            public IVariable<int> number;

            public IRuntimeNode Create()
            {
                return new DebugRuntimeNode
                {
                    number = number.Value()
                };
            }
        }
    }
}
Runtime/SampleRuntimeGraph.cs
using System;
using UnityEngine;

namespace Forte.Runtime
{
    [Serializable]
    public class SampleRuntimeGraph : ScriptableObject
    {
        [SerializeReference]
        public IRuntimeNode.IFactory factory;
    }
}
Runtime/SampleRunner.cs
using UnityEngine;

namespace Forte.Runtime
{
    public class SampleRunner : MonoBehaviour
    {
        [SerializeField]
        private SampleRuntimeGraph graph;

        private void Start()
        {
            for (var i = 0; i < 10; i++)
                graph.factory.Create().Execute();
        }
    }
}
Editor/ISampleNode.cs
using Forte.Runtime;

namespace Forte.Editor
{
    public interface ISampleNode
    {
        public IRuntimeNode.IFactory ToFactory();
    }
}
Editor/ISampleVariableNode.cs
using Forte.Runtime.Variables;

namespace Forte.Editor
{
    public interface ISampleVariableNode<out T>
    {
        public IVariable<T> ToVariable();
    }
}
Editor/RandomNumberNode.cs
using Forte.Runtime.Variables;
using Unity.GraphToolkit.Editor;

namespace Forte.Editor
{
    public class RandomNumberNode : Node, ISampleVariableNode<int>
    {
        private const string NUMBER = "Number";

        public IVariable<int> ToVariable()
        {
            return new RandomNumberVariable();
        }

        protected override void OnDefinePorts(IPortDefinitionContext context)
        {
            base.OnDefinePorts(context);
            context
                .AddOutputPort<int>(NUMBER)
                .Build();
        }
    }
}
Editor/DebugNode.cs
using System;
using Forte.Runtime;
using Forte.Runtime.Variables;
using Unity.GraphToolkit.Editor;

namespace Forte.Editor
{
    [Serializable]
    public class DebugNode : Node, ISampleNode
    {
        private const string NUMBER = "Number";

        public IRuntimeNode.IFactory ToFactory()
        {
            return new DebugRuntimeNode.Factory
            {
                number = GetVariable<int>(GetInputPortByName(NUMBER))
            };
        }

        protected override void OnDefinePorts(IPortDefinitionContext context)
        {
            base.OnDefinePorts(context);
            context
                .AddInputPort<int>(NUMBER)
                .Build();
        }

        protected static IVariable<T> GetVariable<T>(IPort port)
        {
            T value = default;
            if (port.isConnected)
                switch (port.firstConnectedPort.GetNode())
                {
                    case IVariableNode variableNode:
                        variableNode.variable.TryGetDefaultValue(out value);
                        return new ConstantVariable<T>
                        {
                            value = value
                        };
                    case IConstantNode constantNode:
                        constantNode.TryGetValue(out value);
                        return new ConstantVariable<T>
                        {
                            value = value
                        };
                    case ISampleVariableNode<T> variableNode:
                        return variableNode.ToVariable();
                }
            else
                port.TryGetValue(out value);

            return new ConstantVariable<T>
            {
                value = value
            };
        }
    }
}
Editor/SampleGraph.cs
using System;
using Unity.GraphToolkit.Editor;
using UnityEditor;

namespace Forte.Editor
{
    [Serializable]
    [Graph(ASSET_EXTENSION, GraphOptions.SupportsSubgraphs)]
    public class SampleGraph : Graph
    {
        public const string ASSET_EXTENSION = "sample";
        private const string GRAPH_NAME = "SampleGraph";

        [MenuItem("Assets/Create/Sample Graph")]
        private static void CreateAssetFile()
        {
            GraphDatabase.PromptInProjectBrowserToCreateNewAsset<SampleGraph>(GRAPH_NAME);
        }
    }
}
Editor/SampleGraphImporter.cs
using System.Linq;
using Forte.Runtime;
using Unity.GraphToolkit.Editor;
using UnityEditor.AssetImporters;
using UnityEngine;

namespace Forte.Editor
{
    [ScriptedImporter(1, SampleGraph.ASSET_EXTENSION)]
    internal class SampleGraphImporter : ScriptedImporter
    {
        public override void OnImportAsset(AssetImportContext ctx)
        {
            var graph = GraphDatabase.LoadGraphForImporter<SampleGraph>(ctx.assetPath);
            if (graph == null)
                return;
            var node = graph.GetNodes()
                .OfType<ISampleNode>()
                .FirstOrDefault();
            var runtimeGraph = ScriptableObject.CreateInstance<SampleRuntimeGraph>();
            runtimeGraph.factory = node?.ToFactory();
            ctx.AddObjectToAsset("SampleRuntimeGraph", runtimeGraph);
            ctx.SetMainObject(runtimeGraph);
        }
    }
}

アーキテクチャ

Factoryパターンによる遅延評価

ノードを直接ランタイムノードに変換するのではなく、ランタイムノードを生成する生成機(Factory)に変換し、RuntimeGraphではその生成機を保持するようにします。

変換フローは次のようになります:

エディタノード → ファクトリ → ランタイムノード
     ↑              ↑           ↑
  エディタ時    インポート時   実行時
  • エディタノード → ファクトリ: アセットインポート時に変換
  • ファクトリ → ランタイムノード: ランタイムの実行時に生成

Factoryクラス内ではIVariable<T>インターフェースという入力値を返すプロバイダーを使用します。
この分離により、実行時にIVariable<T>インターフェースのValue()メソッドを評価することで、動的な値の生成が可能になります。

Runtime実装の詳細

IVariable<T>: 値プロバイダーのインターフェース

ランタイムで値を提供する抽象化層としてIVariable<T>インターフェースを定義します。
ファクトリークラスのフィールドとして保持され、ランタイムノードに変換されるタイミングでValue()メソッドが呼び出されます。
入力ポートからIVariable<T>の取得まではDebugNode.GetVariable<T>()メソッドで行います。

ConstantVariable<T>: 定数値の提供

常に同じ値を返すだけのVariableです。ノードに直接入力値が設定されている場合や組み込みの変数ノードから渡された場合はこのクラスに変換されます。

RandomNumberVariable: ランダム値の生成

呼び出しごとに異なるランダム値を返す実装です。Value()メソッドが呼び出されるたびに、0から99の範囲でランダムな整数を生成します。
これはRandomNumberNodeから生成され、DebugRuntimeNodeの入力として使用されます。

IRuntimeNode: 実行可能ノードのインターフェース

ランタイムで実行されるノードの基本インターフェースを定義します。

  • Execute(): ノードの処理を実行
  • IFactory: ランタイムノードを生成するファクトリのインターフェース

DebugRuntimeNode: ログ出力

DebugNodeから変換されるランタイムノードです。
DebugRuntimeNode.Factoryから入力値を受け取り、Execute()メソッドでその値をログ出力します。

SampleRuntimeGraph: グラフアセット

グラフ全体をScriptableObjectとして保持します。このScriptableObjectがインポートされたグラフアセットとして保存され、ファクトリへの参照を保持します。

SampleRunner

グラフを実際に実行するMonoBehaviourです。Start()メソッドで10回ループし、毎回Create()Execute()を呼び出します。RandomNumberVariableを使用している場合、10回すべて異なるランダム値がログ出力されます。

Editor実装の詳細

ISampleNode: エディタノードのインターフェース

ToFactoryというメソッドを持ち、インポートのタイミングでエディタノードからファクトリに変換できるようにします。

ISampleVariableNode<T>: 入力ノードのインターフェース

入力ノードはこのインターフェースを実装します。
ToVariableというメソッドが定義されており、インポート時にIVariable<T>に変換されます。

RandomNumberNode: ランダム値生成ノード

ランダムな整数値を出力するノードです。
ISampleVariableNode<int>を実装し、ToVariableメソッドでRandomNumberVariableを返します。

DebugNode: ログ出力ノード

整数値を受け取ってログ出力するエディタノードです。
ISampleNodeを実装し、ToFactoryメソッドでDebugRuntimeNode.Factoryを返します。

挙動確認

グラフエディタで以下のようにノードを配置・接続します。

SampleRunnerを使ってシーンを実行すると、コンソールにランダムな整数値が10回出力されることが確認できます。

まとめ

現在試験的に提供されているGraphToolkitですが、大きなバグもなく(今のところ)使いやすくていいフレームワークだと思います。
ただ、柔軟性が高い分、今回のようにアーキテクチャ周りで悩まされることも多々あるため、今後のアップデートでより多くのサンプルやベストプラクティスが提供されることを期待したいですね。

Discussion