Unity运行时节点编辑器框架:从原理到实战,实现可视化编程
1. 项目概述
如果你在Unity项目里做过可视化编程、任务编辑器或者技能配置工具,大概率会和我一样,对Unity Editor下的节点编辑器又爱又恨。爱的是它强大的可视化能力,恨的是它被牢牢锁死在编辑器里,玩家和最终用户根本用不了。每次想做个运行时(Runtime)的流程图、行为树编辑器,都得从零开始造轮子,光是处理节点拖拽、连线、缩放这些基础交互,就能耗掉一周时间。直到我发现了cemuka的UnityRuntimeNodeEditor,这个专门为Unity运行时环境打造的节点编辑器框架,才算是真正找到了“开箱即用”的解决方案。它完全基于Unity的UGUI系统构建,意味着你可以在游戏运行中,让玩家自由地创建、连接和编辑节点,实现真正的运行时可视化编辑功能。无论是做一款允许玩家自定义技能链的RPG,还是一个内置的关卡逻辑编辑器,这个框架都提供了坚实可靠的基础。
2. 核心设计思路与架构解析
2.1 为何选择“纯运行时”方案?
市面上绝大多数Unity节点编辑器,包括Asset Store里一些知名的插件,其核心设计目标都是服务于开发者在编辑器模式下的工作流。它们深度依赖UnityEditor命名空间下的API,例如EditorWindow、Handles、EditorGUI等。这些API在游戏打包后(即Runtime环境下)是完全不可用的。这就导致了一个尴尬的局面:你辛辛苦苦做的编辑器,只能你自己用,无法交付给终端用户。
UnityRuntimeNodeEditor从立项之初就瞄准了这个痛点。它的核心思路非常清晰:彻底抛弃对Editor API的依赖,完全基于Unity的运行时UI组件(RectTransform, Canvas, Graphic等)和事件系统(EventSystem)来构建整个节点编辑器的交互层。这意味着,所有你看到的节点、连线、背景网格、右键菜单,本质上都是UGUI的预制体(Prefab)和控件。这种设计带来了几个关键优势:
- 跨平台无缝运行:由于不依赖任何编辑器特性,打包到WebGL、移动端(iOS/Android)、PC乃至主机平台,节点编辑器都能正常工作。
- 与游戏UI深度集成:节点编辑器本身就是一个UGUI的Canvas,你可以轻松地将其嵌入到游戏任何UI界面中,像操作一个普通的弹窗或面板一样管理它。
- 性能可控:UGUI的渲染和事件处理机制是Unity运行时标准的一部分,其性能表现和优化手段对开发者来说是透明且熟悉的,避免了使用非常规渲染方式带来的性能黑盒。
2.2 核心架构:事件驱动与组件化
框架的架构设计遵循了清晰的责任分离原则,主要包含以下几个核心部分:
- NodeGraph(节点图):这是数据的容器,负责管理所有节点(Node)和连接(Connection)的引用关系。它不关心渲染,只负责维护图结构的逻辑正确性,比如确保一个输出端口只能连接到一个输入端口(如果需要的话)。
- Node(节点):代表图中的一个功能单元。开发者通过创建继承自
Node类的自定义节点预制体来定义其外观和逻辑。每个节点可以包含多个输入/输出端口(Socket)。 - Socket(端口):附着在节点上的连接点,分为
SocketInput和SocketOutput。它们是连线的逻辑锚点,负责处理连接建立的验证(例如数据类型匹配)。 - Connection(连接):可视化表示两个Socket之间的逻辑关系。在渲染层,它通常表现为一条贝塞尔曲线。
- NodeEditor(编辑器控制器):整个框架的“大脑”。它持有NodeGraph的实例,负责协调视图与模型的交互,处理用户输入(拖拽、点击、缩放),并触发相应的事件。我们通过继承
NodeEditor来创建自己的编辑器实例,并在这里订阅各种事件来响应交互。 - Events(事件中心):这是一个静态事件系统,是框架“事件驱动”理念的核心。当用户在编辑器中执行任何操作(如点击画布、拖拽节点、创建连接),
NodeEditor都会通过Events类派发对应的事件。我们的业务逻辑通过订阅这些事件来响应,实现了视图与业务逻辑的解耦。
这种架构使得扩展变得非常容易。你想增加一种新节点?只需创建一个新的Node子类预制体。你想在连接建立时进行特殊校验?订阅Events.OnMakeConnectionEvent事件即可。整个框架像一套精密的乐高积木,提供了标准接口,具体搭建什么,由开发者决定。
3. 快速上手:从零构建一个运行时计算器
理论说再多不如动手试一下。我们用一个简单的“运行时计算器”例子,来演示如何用UnityRuntimeNodeEditor在10分钟内搭出一个可操作的节点编辑器。这个计算器包含数字输入节点和加法运算节点。
3.1 环境准备与项目导入
首先,你需要一个Unity项目(建议使用2021.3或更高版本,框架基于2021.3.3f1开发)。获取框架有两种方式:
- 通过Git URL安装(推荐):在Unity的Package Manager中,点击“+”号,选择“Add package from git URL”,然后输入:
https://github.com/cemuka/UnityRuntimeNodeEditor.git。这种方式便于后续更新。 - 下载源码:直接从GitHub仓库下载项目,将
Assets文件夹下的内容拷贝到你项目的Assets目录中。
导入后,你会在项目里看到一个NodeEditor文件夹,里面包含了所有核心脚本和示例。
3.2 创建自定义节点预制体
我们的计算器需要两种节点:“数值”节点和“加法”节点。
步骤一:创建“FloatNode”预制体
- 在
Resources/Prefabs/Nodes/路径下(如果没有就创建),新建一个UGUI Panel,重命名为FloatNode。 - 为这个Panel添加
Node组件。这是框架识别它的关键。 - 在Panel下创建UI结构:
- 一个
TextMeshPro - Text作为标题栏,可以命名为Header。 - 一个
TMP_InputField作为数值输入框,命名为ValueField。 - 一个
SocketOutput游戏对象,作为输出端口。你可以从示例场景中拷贝一个Socket的预制体,或者自己创建一个Image,然后添加SocketOutput组件。
- 一个
- 将
FloatNode拖成预制体。
步骤二:编写FloatNode脚本创建一个C#脚本FloatNode.cs,继承自Node。
using TMPro; using UnityEngine; public class FloatNode : Node { // 在编辑器中将UI组件拖拽赋值到这里 public TMP_InputField valueField; public SocketOutput outputSocket; public override void Setup() { // 注册端口,这是必须的步骤,让框架知道这个节点有哪些可连接的接口 Register(outputSocket); // 设置节点在编辑器中的显示标题 SetHeader("Float Value"); // 可选:为输入框添加监听,当值改变时,可以触发一些逻辑(如重新计算下游节点) valueField.onValueChanged.AddListener(OnValueChanged); } void OnValueChanged(string newValue) { // 这里可以通知图,该节点的输出值已改变 Debug.Log($"Float value changed to: {newValue}"); } // 序列化:当保存节点图时,保存这个节点的数据 public override void OnSerialize(Serializer serializer) { serializer.Add("value", valueField.text); } // 反序列化:当加载节点图时,加载这个节点的数据 public override void OnDeserialize(Serializer serializer) { string savedValue = serializer.Get("value"); if (!string.IsNullOrEmpty(savedValue)) { valueField.SetTextWithoutNotify(savedValue); } } }将脚本挂载到FloatNode预制体上,并在Inspector面板中将valueField和outputSocket拖拽赋值。
步骤三:创建“AddNode”预制体过程类似,但AddNode需要两个输入端口和一个输出端口。
- 创建
AddNode预制体,添加Node组件。 - UI结构:标题栏,两个
TMP_InputField(或Text来显示输入值),两个SocketInput,一个SocketOutput,一个显示结果的Text。 - 创建脚本
AddNode.cs。
using TMPro; using UnityEngine; public class AddNode : Node { public SocketInput inputSocketA; public SocketInput inputSocketB; public SocketOutput outputSocket; public TMP_Text resultText; // 用于显示计算结果的Text public override void Setup() { Register(inputSocketA); Register(inputSocketB); Register(outputSocket); SetHeader("Add"); // 监听输入端口连接变化事件 inputSocketA.OnConnectionChanged += Recalculate; inputSocketB.OnConnectionChanged += Recalculate; } void Recalculate(Socket socket) { float a = 0, b = 0; // 从连接的节点获取值。这里需要实现一个从上游节点“拉取”数据的逻辑。 // 通常,我们会定义一个接口,例如 INodeWithFloatOutput,然后通过 connection.OtherSocket.Node 来获取该节点并调用其方法。 // 为了简化示例,我们假设能从连接中直接获取一个float值(实际需要更复杂的消息传递机制)。 // 此处仅作演示,实际项目需要设计自己的数据流协议。 if (inputSocketA.HasConnection && inputSocketA.Connections[0].OtherSocket(inputSocketA).Node is FloatNode floatNodeA) { float.TryParse(floatNodeA.valueField.text, out a); } if (inputSocketB.HasConnection && inputSocketB.Connections[0].OtherSocket(inputSocketB).Node is FloatNode floatNodeB) { float.TryParse(floatNodeB.valueField.text, out b); } float sum = a + b; resultText.text = sum.ToString(); // 同样,可以将计算结果“推送”给输出端口连接的下游节点(需要下游节点监听数据变化)。 } public override void OnSerialize(Serializer serializer) { // 保存结果(如果需要) serializer.Add("result", resultText.text); } public override void OnDeserialize(Serializer serializer) { string savedResult = serializer.Get("result"); resultText.text = savedResult; } }注意:这个
Recalculate方法是一个高度简化的示例。在真实的、生产级别的节点编辑器中,你需要设计一套完整的数据流机制。常见做法是:让每个节点实现一个Execute()或GetValue()方法。当需要计算整个图时,从没有输入的节点(源头)开始,递归地调用下游节点的Execute()方法,并传递数据。或者采用事件驱动,当输入节点的值改变时,触发一个事件,通知所有相连的下游节点重新计算。UnityRuntimeNodeEditor框架提供了节点和连接的骨架,但具体的数据流逻辑需要开发者根据业务需求自行实现。
3.3 构建节点编辑器界面与控制器
步骤一:创建编辑器Canvas
- 在场景中创建一个UGUI Canvas,设置合适的渲染模式。
- 在Canvas下创建一个空的RectTransform(如
EditorHolder),它将作为节点编辑器的容器。
步骤二:创建自定义编辑器控制器创建一个脚本MyCalculatorEditor.cs,继承自NodeEditor。
using UnityEngine; using UnityEngine.EventSystems; public class MyCalculatorEditor : NodeEditor { private string _graphSavePath; public override void StartEditor(NodeGraph graph) { base.StartEditor(graph); _graphSavePath = Application.persistentDataPath + "/calculator_graph.json"; // 订阅关键事件 Events.OnGraphPointerClickEvent += OnGraphPointerClick; Events.OnNodePointerClickEvent += OnNodePointerClick; } // 处理画布点击(主要是右键菜单) protected override void OnGraphPointerClick(PointerEventData eventData) { if (eventData.button == PointerEventData.InputButton.Right) { // 使用ContextMenuBuilder构建右键菜单 var ctx = new ContextMenuBuilder() .Add("Create/Float Node", () => CreateNode("Prefabs/Nodes/FloatNode")) .Add("Create/Add Node", () => CreateNode("Prefabs/Nodes/AddNode")) .AddSeparator() .Add("Graph/Load", () => LoadGraph(_graphSavePath)) .Add("Graph/Save", () => SaveGraph(_graphSavePath)) .Build(); SetContextMenu(ctx); DisplayContextMenu(); } else if (eventData.button == PointerEventData.InputButton.Left) { CloseContextMenu(); } } // 处理节点点击(右键菜单) protected override void OnNodePointerClick(Node node, PointerEventData eventData) { if (eventData.button == PointerEventData.InputButton.Right) { var ctx = new ContextMenuBuilder() .Add("Duplicate", () => DuplicateNode(node)) .Add("Delete", () => DeleteNode(node)) .Build(); SetContextMenu(ctx); DisplayContextMenu(); } } // 辅助方法:在鼠标位置创建节点 private void CreateNode(string prefabPath) { // graph 是基类NodeEditor中管理的当前图实例 var newNode = graph.Create(prefabPath); if (newNode != null) { // 将节点位置设置为当前鼠标在画布上的位置 newNode.RectTransform.anchoredPosition = GetLocalMousePosition(); } } private Vector2 GetLocalMousePosition() { Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle( editorHolder, // editorHolder是NodeEditor中持有的容器RectTransform Input.mousePosition, null, out localPos ); return localPos; } }步骤三:场景组装与启动
- 在场景中创建一个空物体,如
GameManager。 - 将
MyCalculatorEditor脚本挂载上去。 - 在
GameManager上再添加一个脚本GameStartup.cs,用于初始化。
using UnityEngine; public class GameStartup : MonoBehaviour { public RectTransform editorHolder; // 在Inspector中拖拽赋值 public MyCalculatorEditor nodeEditor; // 在Inspector中拖拽赋值(上一步挂载的组件) void Start() { if (editorHolder == null || nodeEditor == null) { Debug.LogError("EditorHolder or NodeEditor not assigned!"); return; } // 关键步骤:创建一个新的节点图,并启动编辑器 NodeGraph newGraph = nodeEditor.CreateGraph<NodeGraph>(editorHolder); nodeEditor.StartEditor(newGraph); Debug.Log("Runtime Node Editor Started! Right-click on the canvas to add nodes."); } }- 在Unity编辑器中,将场景里的
EditorHolder(Canvas下的那个空物体)和挂载了MyCalculatorEditor的物体,分别拖拽到GameStartup脚本的对应公开字段上。 - 运行游戏。在Game视图里,你应该能看到一个灰色的画布。在画布上右键点击,就会出现“Create/Float Node”和“Create/Add Node”的菜单。创建节点后,你可以拖动它们,并从输出端口拖拽到输入端口来创建连线。
至此,一个最基础的运行时节点编辑器就搭建完成了。虽然我们的“加法”计算逻辑还需要完善数据流,但整个编辑器的交互骨架——创建、删除、拖拽、连线、右键菜单、保存加载——已经全部就绪。
4. 核心功能深度解析与高级用法
4.1 序列化与持久化:如何保存你的节点图
框架内置了基于JSON的序列化机制,通过NodeSerializer类实现。它本质上遍历图中的所有节点和连接,调用每个节点的OnSerialize方法,并将所有数据组织成一个JSON对象保存。
自定义序列化数据: 正如我们在FloatNode和AddNode的示例中看到的,你需要重写OnSerialize和OnDeserialize方法。Serializer参数提供了简单的Add和Get方法来存储键值对。对于复杂的数据结构(如类、数组、字典),建议在节点内部使用JsonUtility.ToJson将其转换为字符串后再存储,加载时再解析回来。
public class ComplexNode : Node { public MyComplexData data; [System.Serializable] public class MyComplexData { public string name; public int[] values; public List<SubData> list; } public override void OnSerialize(Serializer serializer) { string jsonData = JsonUtility.ToJson(data); serializer.Add("complexData", jsonData); } public override void OnDeserialize(Serializer serializer) { string jsonData = serializer.Get("complexData"); if (!string.IsNullOrEmpty(jsonData)) { data = JsonUtility.FromJson<MyComplexData>(jsonData); } } }保存与加载调用: 在你的编辑器控制器(如MyCalculatorEditor)中,调用基类提供的SaveGraph(path)和LoadGraph(path)方法即可。路径通常使用Application.persistentDataPath,以保证在各类平台上有写入权限。
4.2 事件系统:响应所有用户交互
Events类是框架的神经中枢。熟练使用它是实现复杂交互的关键。以下是一些常用事件:
OnGraphPointerClickEvent:点击画布空白处。OnNodePointerClickEvent:点击某个节点。OnNodePointerDragEvent:拖拽节点。OnConnectionPointerClickEvent:点击某条连接线。OnMakeConnectionEvent:成功创建一条新连接时触发。这是实现连接有效性验证(如数据类型检查)的最佳位置。OnRemoveConnectionEvent:连接被删除时触发。OnContextMenuEvent:右键菜单显示前触发,可用于动态修改菜单项。
示例:实现连接类型验证假设我们有两种数据类型:Float和String,只有类型相同的端口才能连接。
public class TypedNodeEditor : NodeEditor { public override void StartEditor(NodeGraph graph) { base.StartEditor(graph); Events.OnMakeConnectionEvent += OnMakeConnection; } private void OnMakeConnection(SocketInput input, SocketOutput output) { // 假设我们的Socket组件上有一个自定义的`DataType`属性 TypedSocket typedInput = input as TypedSocket; TypedSocket typedOutput = output as TypedSocket; if (typedInput != null && typedOutput != null && typedInput.dataType != typedOutput.dataType) { Debug.LogWarning($"Connection failed: Type mismatch. Input is {typedInput.dataType}, Output is {typedOutput.dataType}"); // 阻止连接建立 input.Disconnect(output); // 或者可以在这里显示一个错误提示 } } }你需要创建自定义的TypedSocket类,继承自SocketInput/SocketOutput,并添加dataType属性。
4.3 自定义外观与交互:超越默认样式
框架的默认样式可能不符合你的项目美术风格。别担心,几乎所有视觉元素都可以定制。
- 节点样式:节点本身就是一个UGUI预制体。你可以随意修改其背景Image、字体、颜色、布局。只需确保核心组件(如
Node脚本、Socket游戏对象)存在并被正确引用。 - 连接线样式:连接线的渲染通常由一个独立的类处理(可能在框架内部)。你可以通过继承并重写相关渲染逻辑来改变连线的颜色、粗细、纹理(例如虚线、箭头)。查找框架中负责绘制
Connection的类(可能是ConnectionRenderer),创建自己的子类并替换默认的渲染器。 - 画布背景与网格:编辑器的背景是一个独立的UGUI元素。你可以替换其材质或图片,实现自定义网格、背景图或纯色。
- 右键菜单:
ContextMenuBuilder创建的是默认样式的菜单。你可以完全不用它,自己用UGUI搭建一个更漂亮的菜单预制体,然后在响应事件时实例化并显示它。
4.4 性能优化要点
当节点图变得非常庞大(数百甚至上千个节点)时,性能可能成为问题。以下是一些优化思路:
- 虚拟化/分块加载:只渲染视口内的节点。这需要修改框架的渲染逻辑,对于超大型图是终极解决方案,但实现复杂。
- 简化节点UI:避免在节点中使用过于复杂的UI层级和大量Graphic组件。使用简单的Image和Text,禁用不必要的Raycast Target。
- 连接线渲染优化:连线是性能消耗大户,特别是贝塞尔曲线。如果连线是直线,可以考虑使用更简单的
UILineRenderer或直接使用缩放旋转的RectTransform。对于静态的、不再变化的图,可以考虑将整个编辑器渲染到一张RenderTexture上。 - 事件订阅管理:确保在编辑器关闭或销毁时,及时取消订阅所有事件(
Events.OnXXXEvent -= ...),防止内存泄漏。 - 对象池:频繁创建和删除节点时,可以考虑实现节点和连接线的对象池,复用游戏对象,减少GC压力。
5. 实战避坑指南与常见问题
在实际项目中使用UnityRuntimeNodeEditor,我踩过不少坑,也总结出一些让开发更顺畅的经验。
5.1 节点数据流设计模式
框架不强制规定数据流模式,但这恰恰是最容易出问题的地方。我推荐两种经过验证的模式:
模式一:拉取模式(Pull)定义一个接口,如IOutputValue<T>,让能输出数据的节点实现它。
public interface IOutputValue<T> { T GetOutputValue(string socketId = null); // socketId用于区分节点的多个输出 }在需要数据的节点(如AddNode)中,通过连接找到上游节点,并尝试获取接口来拉取数据。
if (inputSocketA.HasConnection) { var connectedNode = inputSocketA.Connections[0].OtherSocket(inputSocketA).Node; var valueProvider = connectedNode as IOutputValue<float>; if (valueProvider != null) { float valueA = valueProvider.GetOutputValue(); // ... 使用 valueA } }优点:按需计算,逻辑清晰。缺点:可能存在循环依赖,需要检测。
模式二:推送模式(Push)/ 事件驱动当源节点数据变化时,触发一个事件。所有连接到其输出端口的节点都订阅该事件,并重新计算。
public class FloatNode : Node, IOutputValue<float> { public event Action<float> OnValueChanged; private float _currentValue; public void SetValue(float newValue) { _currentValue = newValue; OnValueChanged?.Invoke(_currentValue); } // ... }在AddNode的Setup中,订阅上游节点的事件。优点:响应及时,适合实时反馈。缺点:事件链管理复杂,容易形成复杂的事件网。
我的建议:对于逻辑相对静态的编辑器(如技能配置),使用拉取模式,在最终执行或预览时统一计算整个图。对于需要实时视觉反馈的编辑器(如材质节点编辑器),使用推送模式。
5.2 常见问题与排查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 运行时右键没有菜单 | 1.ContextMenuBuilder构建的菜单预制体未正确加载或初始化。2. 画布或 EditorHolder的Raycast设置有问题。3. 事件订阅未成功。 | 1. 检查Resources文件夹下是否有默认的右键菜单预制体,或检查自定义菜单的路径。2. 确保画布和 EditorHolder的Graphic Raycaster组件启用,且没有被其他全屏UI遮挡。3. 在 StartEditor中确认Events.OnGraphPointerClickEvent等事件已正确订阅。 |
| 节点无法拖拽 | 1. 节点的RectTransform锚点或轴心设置异常,导致拖拽偏移。 2. 节点或其父物体的Canvas Group的 Blocks Raycasts被禁用。 | 1. 检查节点预制体的RectTransform设置,通常锚点设为(0.5, 0.5),轴心设为(0.5, 0.5)便于控制。2. 确保从节点到根Canvas路径上所有Canvas Group的交互属性正确。 |
| 连线创建不成功或看不见 | 1. Socket的RectTransform尺寸为0,导致点击区域无效。2. Socket游戏对象未激活,或缺少必要的碰撞体/射线检测组件(框架可能依赖 Image组件)。3. 连接线的渲染层(Sorting Order)在节点下方。 | 1. 给Socket对象添加一个足够大的Image组件(可将颜色调为透明用于调试),确保其RectTransform有宽高。2. 检查Socket预制体及其所有组件是否完整启用。 3. 调整连接线渲染器或节点的Canvas Sort Order。 |
| 保存/加载后节点位置错乱 | 序列化保存的是节点的anchoredPosition,但加载时节点的父物体或锚点预设可能发生了变化。 | 确保节点在保存和加载时,其所在的层级结构(父物体)是一致的。序列化/反序列化只处理相对位置。 |
| 在UI滚动视图(ScrollRect)内使用编辑器时,操作失灵 | NodeEditor默认的输入处理可能和ScrollRect的拖拽冲突。ScrollRect会“吃掉”一些拖拽事件。 | 1. 可以考虑将编辑器放在独立的Canvas中,不与ScrollRect嵌套。 2. 或者,通过修改 NodeEditor的输入检测逻辑,当在节点或画布上开始拖拽时,暂时禁用ScrollRect的拖拽功能。这需要更深入的代码修改。 |
自定义节点脚本的Setup方法不执行 | 自定义节点预制体上的Node子类脚本没有被正确实例化或初始化流程未触发。 | 1. 确认节点是通过graph.Create(prefabPath)创建的,而不是直接Instantiate。2. 确认你的节点类重写了 Setup方法,而不是Start或Awake。框架在节点创建后会调用Setup。 |
5.3 与新版Input System的兼容性
项目说明中提到了支持新旧Input System。如果你在使用Unity的新Input System Package,需要确保:
- 在Player Settings中正确激活了Input System。
- 场景中需要有
InputSystemUIInputModule组件(替代旧的StandaloneInputModule)来处理UI输入。 框架内部可能通过EventSystem.current.currentInputModule来获取输入,只要EventSystem指向正确的Input Module,通常就能无缝工作。如果遇到点击无响应,首先检查EventSystem使用的是哪个Module。
从零开始搭建一个功能完备的运行时节点编辑器是一项庞大的工程,而UnityRuntimeNodeEditor框架将其中最复杂、最通用的部分——交互骨架——完美地封装了起来。它就像为你准备好了汽车的底盘、发动机和方向盘,让你可以专注于打造车身的造型和内饰(即你的业务逻辑和美术风格)。无论是用于游戏内的可视化脚本、角色对话树、任务流程图,还是工具类的材质编辑、动画状态机混合,这个框架都能提供一个极高效率的起点。它的开源属性也意味着你可以深入代码,按需修改,使其完全贴合你的项目。唯一需要投入时间深入设计的,就是你节点之间流动的数据协议,这是赋予整个编辑器灵魂的关键。
