Unity节点编辑器开发指南:从零构建可视化编程工具
1. 项目概述:为什么我们需要一个自己的节点编辑器?
如果你在Unity里做过稍微复杂一点的编辑器工具,比如行为树、状态机、材质编辑器,或者想可视化地配置一些数据流程,那你大概率会和我一样,对Unity自带的Inspector面板感到束手束脚。Inspector擅长处理单个组件的属性,但当你需要表达“节点A的输出连接到节点B的输入,并且根据条件C决定是否执行”这类关系时,它就无能为力了。这时候,一个自定义的节点编辑器就成了刚需。
市面上有很多优秀的节点编辑器插件,比如xNode、NodeCanvas,它们功能强大,开箱即用。但直接使用插件,你可能会遇到几个问题:一是插件过于庞大,你只想实现一个简单的流程图,却要引入一整套复杂的框架;二是当插件的行为不符合你的预期,或者你想实现一个非常特定的功能时,修改插件源码的难度可能比从头写一个还大;三是对于学习和理解Unity编辑器扩展的底层机制来说,自己动手造一次轮子,收获远比单纯使用插件要大得多。
这个项目,就是带你从零开始,用Unity最基础的EditorWindow和GUIStyle,亲手搭建一个功能完备的节点编辑器框架。我们不会依赖任何第三方插件,所有功能都基于Unity Editor API实现。通过这个过程,你将彻底掌握如何在Unity中创建自定义窗口、绘制复杂的GUI、处理用户交互(拖拽、选择、连接),并最终构建出一个可扩展、可复用的节点系统核心。这不仅是完成一个工具,更是深入理解Unity编辑器开发精髓的绝佳路径。
2. 核心架构设计:数据与视图的分离
在动手写代码之前,我们先要理清节点编辑器的核心架构。一个健壮的编辑器,必须遵循数据与视图分离的原则。这能保证我们的节点数据可以独立于UI被序列化、保存、加载,也便于未来扩展不同的视图表现(比如从2D节点图切换到列表视图)。
2.1 数据层:Node与NodeGraph
数据层的核心是两个类:Node和NodeGraph。
Node类代表一个节点。它至少需要包含以下信息:
- 唯一标识符 (GUID):用于在图中唯一标识一个节点,通常使用
System.Guid生成字符串。这是节点之间建立连接关系的关键。 - 位置 (Rect):节点在画布上的位置和大小。
- 标题和内容:节点上显示的文字信息。
- 输入/输出端口 (Ports):定义节点如何与其他节点连接。每个端口需要有自己的唯一ID、类型、方向(输入/输出)以及它所连接的另一个端口的ID列表(对于输出端口,可能连接多个输入端口)。
NodeGraph类则是一个节点容器,它管理着图中所有的Node实例,以及它们之间的连接关系。它负责数据的序列化(保存为ScriptableObject资产)和反序列化。
实操心得:在定义端口时,我强烈建议为端口定义一个
PortType枚举(如Flow流程,Integer整数,Boolean布尔,String字符串等)。这样,在绘制连接线时,你可以通过颜色来区分不同类型(比如流程线用白色,数据线用蓝色),并且在连接时进行类型检查,防止将整数输出连接到布尔输入上,这能极大提升编辑器的可用性和健壮性。
2.2 视图层:NodeEditorWindow
视图层的主体是一个继承自EditorWindow的类,例如NodeEditorWindow。这个窗口负责:
- 绘制画布背景:通常是一个网格。
- 绘制所有节点:遍历
NodeGraph中的节点数据,调用每个节点的DrawNode方法。 - 绘制连接线:根据节点间的连接关系,在端口之间绘制贝塞尔曲线。
- 处理用户交互:包括鼠标点击选择节点、拖拽节点、拖拽创建连接线、框选、删除等。
- 管理编辑器状态:比如当前选中的节点、正在拖拽的连接线、视图的缩放和平移偏移量等。
这里的关键是,视图层不直接修改节点数据的位置(Rect)。当用户拖拽节点时,视图层记录的是拖拽的偏移量,在拖拽结束后,再将这个偏移量应用到节点数据的Rect上,然后标记数据为“脏”以触发保存。这种“状态-应用”的模式比直接修改数据更清晰。
2.3 交互状态机
节点编辑器的交互比较复杂,我们需要一个清晰的状态机来管理。通常有以下几种状态:
- None:无操作状态,可以点击选择。
- DraggingNode:正在拖拽一个或多个节点。
- DraggingSelectionBox:正在拖拽鼠标进行框选。
- DraggingConnection:从一个端口拖拽出一根新的连接线(尚未连接到目标端口)。
- Panning:拖拽画布背景进行平移。
在OnGUI方法中,我们根据当前的Event.current.type(如MouseDown,MouseDrag,MouseUp,Repaint)和Event.current.mousePosition,结合当前状态,来决定执行什么操作并切换到下一个状态。把这部分逻辑理清楚,后续的编码就会顺畅很多。
3. 核心实现:EditorWindow与自定义绘制
有了架构设计,我们就可以开始动手实现了。首先从创建编辑器窗口和绘制基础元素开始。
3.1 创建并布局EditorWindow
创建一个新的C#脚本,继承自EditorWindow。使用[MenuItem(“Window/My Node Editor”)]属性来在Unity编辑器菜单中添加打开窗口的选项。
using UnityEditor; using UnityEngine; public class NodeEditorWindow : EditorWindow { private NodeGraph currentGraph; private Vector2 graphOffset; private float graphZoom = 1.0f; [MenuItem(“Window/Node System/Node Editor”)] public static void OpenWindow() { var window = GetWindow<NodeEditorWindow>(); window.titleContent = new GUIContent(“Node Editor”); } private void OnGUI() { // 处理画布事件(缩放、平移) HandleEvents(); // 开始一个可以应用缩放和平移的矩阵区域 Matrix4x4 originalMatrix = GUI.matrix; GUI.matrix = Matrix4x4.TRS(new Vector3(graphOffset.x, graphOffset.y, 0), Quaternion.identity, new Vector3(graphZoom, graphZoom, 1)); DrawGrid(20, 0.2f, Color.gray); // 绘制细网格 DrawGrid(100, 0.4f, Color.gray); // 绘制粗网格 // 如果有加载的图,绘制节点和连接 if (currentGraph != null) { DrawConnections(); DrawNodes(); } GUI.matrix = originalMatrix; // 恢复矩阵 // 绘制UI覆盖层(如工具栏,状态信息) DrawOverlay(); } private void HandleEvents() { Event e = Event.current; switch (e.type) { case EventType.ScrollWheel: // 实现缩放 Vector2 mouseBeforeZoom = ScreenToGraphPosition(e.mousePosition); graphZoom -= e.delta.y * 0.01f; graphZoom = Mathf.Clamp(graphZoom, 0.1f, 2.0f); Vector2 mouseAfterZoom = ScreenToGraphPosition(e.mousePosition); graphOffset += (mouseAfterZoom - mouseBeforeZoom) * graphZoom; e.Use(); break; case EventType.MouseDrag: if (e.button == 2 || (e.button == 0 && e.alt)) // 中键或Alt+左键拖拽画布 { graphOffset += e.delta; e.Use(); } break; } } private Vector2 ScreenToGraphPosition(Vector2 screenPos) { return (screenPos - graphOffset) / graphZoom; } }HandleEvents方法处理了画布的缩放和平移。缩放的核心技巧是:以鼠标当前位置为缩放中心。我们记录缩放前鼠标在图坐标系中的位置,缩放后再次计算,两者的差值就是画布需要补偿的偏移量,这样缩放时视觉焦点就会保持在鼠标指针处,体验非常自然。
3.2 深度定制GUIStyle:让节点“活”起来
Unity默认的GUI样式比较简陋。要绘制一个美观的节点,我们必须深入使用GUIStyle。节点的外观通常由几部分组成:一个背景框、一个标题栏、内容区域和端口。
我们可以通过代码动态创建GUIStyle:
private GUIStyle nodeStyle; private GUIStyle selectedNodeStyle; private GUIStyle nodeTitleStyle; private void CreateStyles() { if (nodeStyle == null) { nodeStyle = new GUIStyle(); nodeStyle.normal.background = MakeTex(2, 2, new Color(0.3f, 0.3f, 0.3f, 0.95f)); nodeStyle.border = new RectOffset(12, 12, 12, 12); // 定义九宫格拉伸的边界 nodeStyle.padding = new RectOffset(20, 20, 10, 10); selectedNodeStyle = new GUIStyle(nodeStyle); selectedNodeStyle.normal.background = MakeTex(2, 2, new Color(0.4f, 0.5f, 0.4f, 0.95f)); nodeTitleStyle = new GUIStyle(EditorStyles.boldLabel); nodeTitleStyle.alignment = TextAnchor.MiddleCenter; nodeTitleStyle.normal.textColor = Color.white; } } private Texture2D MakeTex(int width, int height, Color col) { Color[] pix = new Color[width * height]; for (int i = 0; i < pix.Length; ++i) pix[i] = col; Texture2D result = new Texture2D(width, height); result.SetPixels(pix); result.Apply(); return result; }MakeTex是一个创建纯色纹理的实用函数。nodeStyle.border的设置是关键,它定义了“九宫格”拉伸的边界。当节点大小变化时,只有边框中间的部分被拉伸,四个角保持不变,这样无论节点多大,圆角都能保持完美。
在DrawNode函数中,我们使用GUI.Box绘制节点主体,用GUI.Label绘制标题,并在合适的位置(通常是在节点左侧和右侧)计算并绘制输入/输出端口。端口可以绘制成一个小圆形或三角形,并检测鼠标是否悬停或点击。
3.3 绘制连接线:贝塞尔曲线的艺术
节点之间的连接线是节点编辑器的灵魂。直线看起来太生硬,我们使用贝塞尔曲线来绘制。Unity的Handles类或GUI类中的DrawBezier方法可以帮我们实现。
计算曲线的起点、终点和两个控制点。一个常见的技巧是控制点的方向与端口的方向一致(例如,输出端口的控制点向右,输入端口的控制点向左),并且控制点的距离与两个节点间的水平距离成一定比例,这样画出来的曲线自然流畅。
private void DrawConnection(Vector2 startPos, Vector2 endPos, Color color, float width = 3f) { Vector2 startTangent = startPos + Vector2.right * 50; Vector2 endTangent = endPos + Vector2.left * 50; Handles.color = color; Handles.DrawBezier(startPos, endPos, startTangent, endTangent, color, null, width); // 也可以绘制一个方向箭头(在终点处画一个三角形) DrawArrow(endPos, (endPos - endTangent).normalized, color); }注意事项:
Handles的绘制必须在OnGUI的Repaint事件中,或者在Handles.BeginGUI()/Handles.EndGUI()块内进行,以确保在正确的绘制阶段渲染。此外,由于我们之前对整个画布应用了缩放和平移的矩阵变换,直接使用Handles.DrawBezier绘制的曲线不会受此变换影响。你需要将曲线的坐标从“图坐标系”转换回“屏幕坐标系”,或者使用GUI类的绘制方法并在绘制前应用相同的矩阵。
4. 交互逻辑的实现:拖拽、选择与连接
绘制是静态的,交互才是动态的核心。我们需要处理各种鼠标事件来让节点“可操作”。
4.1 节点的拖拽与选择
在DrawNodes循环中,为每个节点创建一个Rect,并使用GUI.Box绘制。为了处理交互,我们需要为这个Rect区域分配一个唯一的控件ID(使用GUIUtility.GetControlID),然后通过GUI.Button或直接检查事件来捕获交互。
private void ProcessNodeEvents(Node node) { Rect nodeRect = GetNodeRect(node); // 计算节点在屏幕坐标系中的Rect int controlID = GUIUtility.GetControlID(FocusType.Passive); switch (Event.current.type) { case EventType.MouseDown: if (nodeRect.Contains(Event.current.mousePosition)) { if (Event.current.button == 0) // 左键点击 { // 处理选择:可以单独选,也可以按住Ctrl多选 if (!Event.current.control) { ClearSelection(); } AddToSelection(node); dragOffset = Event.current.mousePosition - node.position.position; currentState = EditorState.DraggingNode; Event.current.Use(); } else if (Event.current.button == 1) // 右键点击 { // 显示节点上下文菜单 ShowNodeContextMenu(node); Event.current.Use(); } } break; case EventType.MouseDrag: if (currentState == EditorState.DraggingNode && IsSelected(node)) { // 更新所有被选中节点的位置 Vector2 delta = Event.current.delta / graphZoom; // 注意缩放影响 foreach (var selectedNode in selectedNodes) { selectedNode.position.position += delta; } isDirty = true; Event.current.Use(); } break; case EventType.MouseUp: if (currentState == EditorState.DraggingNode) { currentState = EditorState.None; Event.current.Use(); } break; } }这里的关键是状态管理。在MouseDown时设置状态和拖拽起始偏移量,在MouseDrag时根据状态和偏移量更新节点位置,在MouseUp时重置状态。
4.2 连接线的创建与删除
创建连接通常始于在一个端口上按下鼠标左键。我们进入DraggingConnection状态,并记录起始端口。然后,在OnGUI中,无论鼠标移动到哪里,都实时绘制一条从起始端口到当前鼠标位置的临时连接线。
private void ProcessPortEvents(Port port, Rect portRect) { int controlID = GUIUtility.GetControlID(FocusType.Passive); Event e = Event.current; switch (e.type) { case EventType.MouseDown: if (portRect.Contains(e.mousePosition) && e.button == 0) { draggingPort = port; currentState = EditorState.DraggingConnection; e.Use(); } break; case EventType.MouseUp: if (currentState == EditorState.DraggingConnection && e.button == 0) { // 检查鼠标是否释放在另一个端口上 Port targetPort = GetPortAtPosition(e.mousePosition); if (targetPort != null && targetPort != draggingPort && IsConnectionValid(draggingPort, targetPort)) { // 创建有效连接 CreateConnection(draggingPort, targetPort); } // 清理状态 draggingPort = null; currentState = EditorState.None; e.Use(); } break; } }当鼠标释放时,我们检查释放位置是否存在另一个有效的端口(类型匹配、方向相反、不是同一个节点等)。如果有效,就在数据层(NodeGraph)中建立这两个端口的连接关系。删除连接则通常通过在连接线上右键点击,弹出上下文菜单选择“Delete”来实现。
4.3 框选与多选
框选功能提升了编辑效率。实现思路是:在画布空白处按下鼠标左键并拖拽时,进入DraggingSelectionBox状态,并记录起始点。拖拽时,实时计算一个矩形选择框。在MouseUp时,遍历所有节点,判断其位置是否与选择框相交,将相交的节点加入选择集。
Rect selectionBox = new Rect(); Vector2 selectionStartPos; // 在画布背景事件处理中 case EventType.MouseDown: if (e.button == 0 && !IsMouseOverAnyNode(e.mousePosition)) { selectionStartPos = ScreenToGraphPosition(e.mousePosition); currentState = EditorState.DraggingSelectionBox; e.Use(); } break; case EventType.MouseDrag: if (currentState == EditorState.DraggingSelectionBox) { Vector2 currentPos = ScreenToGraphPosition(e.mousePosition); selectionBox = new Rect( Mathf.Min(selectionStartPos.x, currentPos.x), Mathf.Min(selectionStartPos.y, currentPos.y), Mathf.Abs(currentPos.x - selectionStartPos.x), Mathf.Abs(currentPos.y - selectionStartPos.y) ); e.Use(); Repaint(); // 请求重绘以更新选择框显示 } break; case EventType.MouseUp: if (currentState == EditorState.DraggingSelectionBox) { SelectNodesInRect(selectionBox); currentState = EditorState.None; selectionBox = new Rect(); e.Use(); Repaint(); } break; case EventType.Repaint: if (currentState == EditorState.DraggingSelectionBox && selectionBox.width > 0 && selectionBox.height > 0) { // 使用GUI绘制一个半透明的选择框 EditorGUI.DrawRect(selectionBox, new Color(0.5f, 0.8f, 1f, 0.2f)); Handles.DrawSolidRectangleWithOutline(selectionBox, Color.clear, new Color(0.5f, 0.8f, 1f)); } break;SelectNodesInRect函数遍历图数据中的所有节点,将其位置(node.position)与selectionBox进行比较,使用Rect.Overlaps或Rect.Contains方法判断是否选中。
5. 数据持久化与序列化
一个编辑器工具如果不能保存劳动成果,那就毫无意义。在Unity中,序列化编辑器数据最常用的方式是使用ScriptableObject。
5.1 创建NodeGraph ScriptableObject
using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName = “New Node Graph”, menuName = “Node System/Node Graph”)] public class NodeGraph : ScriptableObject { [SerializeField] private List<Node> nodes = new List<Node>(); [SerializeField] private List<NodeConnection> connections = new List<NodeConnection>(); // 属性、方法,用于访问和修改nodes和connections public List<Node> Nodes => nodes; public List<NodeConnection> Connections => connections; public Node CreateNode(System.Type nodeType, Vector2 position) { // 使用ScriptableObject.CreateInstance创建特定类型的节点 Node node = ScriptableObject.CreateInstance(nodeType) as Node; node.Initialize(this, position); nodes.Add(node); AssetDatabase.AddObjectToAsset(node, this); // 重要:将节点作为子资产添加 EditorUtility.SetDirty(this); AssetDatabase.SaveAssets(); return node; } public void DeleteNode(Node node) { nodes.Remove(node); // 同时需要删除所有与该节点相关的连接 connections.RemoveAll(c => c.inputNodeId == node.GUID || c.outputNodeId == node.GUID); // 销毁子资产 DestroyImmediate(node, true); EditorUtility.SetDirty(this); AssetDatabase.SaveAssets(); } }Node类本身也应该继承自ScriptableObject,这样每个节点实例都能被Unity的序列化系统管理。AssetDatabase.AddObjectToAsset是关键,它将节点作为子对象嵌入到.asset文件中,确保节点数据与图数据一起保存。
5.2 在EditorWindow中加载与保存
在NodeEditorWindow中,我们需要提供打开、保存、创建新图的功能。可以使用EditorGUILayout.ObjectField来引用一个NodeGraph资产。
private void DrawToolbar() { GUILayout.BeginHorizontal(EditorStyles.toolbar); // 创建新图 if (GUILayout.Button(“New”, EditorStyles.toolbarButton)) { if (EditorUtility.DisplayDialog(“Create New Graph”, “Save current graph?”, “Yes”, “No”)) { SaveGraph(); } currentGraph = null; graphOffset = Vector2.zero; graphZoom = 1.0f; } // 加载图 if (GUILayout.Button(“Load”, EditorStyles.toolbarButton)) { string path = EditorUtility.OpenFilePanel(“Load Node Graph”, “Assets”, “asset”); if (!string.IsNullOrEmpty(path)) { path = “Assets” + path.Substring(Application.dataPath.Length); var graph = AssetDatabase.LoadAssetAtPath<NodeGraph>(path); if (graph != null) { currentGraph = graph; } } } // 保存图 if (GUILayout.Button(“Save”, EditorStyles.toolbarButton) && currentGraph != null) { SaveGraph(); } // 显示当前图名称 GUILayout.Label(currentGraph != null ? currentGraph.name : “No Graph Loaded”, EditorStyles.toolbarTextField); GUILayout.EndHorizontal(); } private void SaveGraph() { if (currentGraph != null) { EditorUtility.SetDirty(currentGraph); AssetDatabase.SaveAssets(); } }踩坑记录:在编辑器脚本中修改了
ScriptableObject的数据后,必须调用EditorUtility.SetDirty(targetObject)来标记对象为“脏”,否则Unity可能不会自动保存你的更改。在AssetDatabase.SaveAssets()时,所有被标记为“脏”的资产才会被写入磁盘。另外,当通过代码创建或删除作为子资产的节点时,务必调用AssetDatabase.AddObjectToAsset和DestroyImmediate(obj, true),并随后保存资产,否则可能会在资产文件中留下“幽灵”对象或导致引用丢失。
6. 性能优化与高级技巧
当节点数量成百上千时,性能问题就会凸显。以下是几个关键的优化方向。
6.1 视口裁剪
这是最有效的优化。我们不需要绘制画布上不可见的节点和连接线。在DrawNodes和DrawConnections之前,先计算当前窗口的视口在“图坐标系”下的矩形范围。
Rect viewportRect = new Rect( -graphOffset.x / graphZoom, -graphOffset.y / graphZoom, position.width / graphZoom, position.height / graphZoom ); foreach (var node in currentGraph.Nodes) { if (node.position.Overlaps(viewportRect)) { DrawNode(node); } }对于连接线,判断稍微复杂一些。你可以检查连接线的起点和终点所在的节点是否在视口内,或者计算连接线的包围盒。一个简单但有效的优化是,只绘制那些起点或终点节点在视口内的连接线。
6.2 避免频繁的GUI.Repaint
Repaint()会强制窗口立即重绘,频繁调用会导致CPU使用率飙升。我们应该只在必要时调用它:
- 用户交互时(拖拽、缩放)。
- 数据发生改变时(添加/删除节点、创建连接)。
对于连续的操作(如拖拽节点),可以在MouseDrag事件中调用Repaint()。但对于一些不直接影响视觉的变化,可以尝试使用EditorApplication.delayCall来将重绘请求推迟到下一帧。
6.3 使用GUILayout与GUI的权衡
GUILayout系统自动布局很方便,但在需要精确控制位置和性能敏感的编辑器窗口(如节点编辑器)中,直接使用GUI方法(GUI.Button,GUI.Label,GUI.Box)并指定Rect参数通常性能更好,也更可控。我们的节点和端口都是基于绝对位置绘制的,因此全程使用GUI是更合适的选择。
6.4 实现撤销/重做
Unity提供了强大的Undo系统。对于节点编辑器的每一个重要操作(移动节点、创建节点、删除连接等),都应该记录Undo。
// 移动节点前 Undo.RecordObject(node, “Move Node”); node.position.position += delta; // 移动节点后不需要额外操作,Undo系统会自动处理 // 创建节点 Undo.RecordObject(currentGraph, “Create Node”); Node newNode = currentGraph.CreateNode(typeof(ActionNode), mousePos); Undo.RegisterCreatedObjectUndo(newNode, “Create Node”); // 删除连接 Undo.RecordObject(currentGraph, “Delete Connection”); currentGraph.Connections.Remove(connection);为操作添加一个有意义的undoName(如“Move Node”)非常重要,它会在Unity的Edit菜单中显示,让用户清楚地知道可以撤销什么操作。Undo.RegisterCreatedObjectUndo对于通过代码创建的对象是必须的,以确保撤销时能正确销毁它。
7. 扩展性与高级功能展望
完成基础框架后,你可以根据项目需求进行无限扩展。
7.1 自定义节点类型系统
建立一个基类BaseNode,然后派生出各种功能节点,如ActionNode,ConditionNode,VariableNode等。每个节点类型可以定义自己独特的端口和绘制逻辑。可以使用反射或特性(Attribute)系统来动态发现和创建节点类型,并在右键菜单中列出。
7.2 节点分组与注释
允许用户将多个节点框选后放入一个“组”中,这个组可以折叠/展开,并且可以添加文本注释。这在大图中管理逻辑模块非常有用。
7.3 搜索与批量操作
实现一个搜索框,可以根据节点标题、类型或内容快速定位节点。支持批量编辑选中节点的公共属性。
7.4 与运行时数据联动
最终,节点编辑器配置的数据需要被游戏运行时使用。你需要设计一套序列化方案,将NodeGraph数据“烘焙”成运行时高效可读的格式,例如生成C#代码、序列化为二进制文件或JSON,并在运行时加载和执行节点逻辑。
从零构建一个节点编辑器是一个系统工程,它考验了你对Unity编辑器API的熟悉程度、GUI编程能力、数据结构设计以及软件架构思维。虽然初期投入较大,但换来的是一套完全受控、高度定制化且深度理解的工具链。当你看到自己亲手打造的编辑器流畅运行,并高效地支撑起项目开发时,那种成就感是使用现成插件无法比拟的。希望这篇指南能为你打下坚实的基础,剩下的创意和功能,就交由你来实现了。