
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 GetWindowNodeEditorWindow(); 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。节点的外观通常由几部分组成一个背景框、一个标题栏、内容区域和端口。我们可以通过代码动态创建GUIStyleprivate 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 ScriptableObjectusing System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName “New Node Graph”, menuName “Node System/Node Graph”)] public class NodeGraph : ScriptableObject { [SerializeField] private ListNode nodes new ListNode(); [SerializeField] private ListNodeConnection connections new ListNodeConnection(); // 属性、方法用于访问和修改nodes和connections public ListNode Nodes nodes; public ListNodeConnection 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.LoadAssetAtPathNodeGraph(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.RepaintRepaint()会强制窗口立即重绘频繁调用会导致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编程能力、数据结构设计以及软件架构思维。虽然初期投入较大但换来的是一套完全受控、高度定制化且深度理解的工具链。当你看到自己亲手打造的编辑器流畅运行并高效地支撑起项目开发时那种成就感是使用现成插件无法比拟的。希望这篇指南能为你打下坚实的基础剩下的创意和功能就交由你来实现了。