前言

本人一直对节点编辑器比较感兴趣,最近看到有网友已经基于GraphView做出了自己的剧情编辑器,技能编辑器等,我也手痒难耐,并且眼馋GraphView的颜值很久了,决定来学习一下,并且准备把自己的技能编辑器移植过来。

NodeGraphProcessor版本:https://github.com/alelievr/NodeGraphProcessor/commit/bea17d70217f44509c30086ec04a4cfbe1836751

NodeGraphProcessor + Odin:https://github.com/wqaetly/NodeGraphProcessor

正文

GraphView介绍

GraphView是Unity推出的一个基于UIElement的节点编辑器UI模块,基建很完全,有多选,拖动,缩放,Group等功能,但是我没有找到官方的文档和示例,大家可以跟着这个UP主学习一下怎么从零开始使用GraphView做一个节点编辑器:Mert Kirimgeri

他还是 NodeBasedDialogueSystem 的作者

v2-e8924f8b5be5891d4a6e8eec444d3a9d_1440w

UIElement介绍

UIElement(现更名为UI ToolKit但是程序集名称还是UIElement,雀食没话说)是Unity新推出的一种UI解决方案,目标是一站式解决Editor+Runtime的UI设计需求,使用C# + HTML的形式进行开发,其中HTML用以定义UI样式和内容,C#引用HTML定义内容+绑定数据,并且与GamePlay进行交互。

我们来看看他的底层是怎么样的:

v2-adabfd52b3a005d5ad859b50c50e86f7_1440w

  • UIElement使用了名为UIElements Renderer(UIR)的渲染后台,为UIElement渲染量身定制,尽可能提高性能。
  • 使用Retained Mode GUI (RMGUI)以及按需更新的模式,当UI元素没有发生变化的时候几乎0消耗。
  • 渲染时分配一个大的VB/IB缓冲区,使用特制的Uber Shader,减少渲染状态切换,一个DrawCall即可完成整个UIElements的绘制(即使UI元素发生变化,也会将发生变化的UI元素的VB/IB放入一开始分配的那个大的VB/IB缓冲区)。
  • 性能优化,把更多的工作下放到GPU去做,减少CPU开销,例如UI元素的属性会被存储在GPU内存上,GPU加速Clip,DynamicTransform(GPU加速UI元素的平移,缩放,旋转操作),GPU加速UI视口操作(缩放,平移,更改分辨率)。

此部分更多内容参见:Built for performance: the UIElements Renderer – Unite Copenhagen 2019

UIElement基础知识

  • UXML是UI元素的布局文件,比如一个按钮其中包含背景图片和文字,可通过 VisualTreeAsset.CloneTree(VisualElement);形式实例化出来
  • USS是UI元素的样式文件,比如一个按钮其中包含背景图片和文字具体的内容和样式,可通过 VisualElement.styleSheets.Add(StyleSheet);的形式将其样式应用到VisualElement上,也可以通过 VisualElement.AddToClassList(XXX) 来将其中某一个样式应用给VisualElement
  • USS中的三个特殊字符:.#space(注意这是一个空格)> ,也就是 CSS中的选择器 ,但在USS中有些许不同,其中 . 用于引用和描述UXML中已有的Class样式,而 # 则是在USS中新声明的一个Class,可以直接通过 new VisualElement{ name = "XXX" };的形式实例化一个VisualElement,空格和CSS中的后代选择器作用一致,最后> 表示取得类的某个子元素。三者可以灵活搭配使用。

RMGUI和IMGUI对比

上面提到UIElement使用RMGUI的模式,并且基于其做了按需更新的模式达到性能优化的效果,但目前Unity编辑器拓展主流仍然是Immediate Mode GUI (IMGUI)的形式,他不保存任何状态写起来非常爽快,但是问题就是需要每帧无差别的收集所有VB/IB然后进行绘制操作,相比RMGUI按需更新的模式就比较消耗性能。

节点编辑器选型对比

经过上面的描述,我们可以得知RMGUI模式的UIElement是更加契合我们的节点编辑器,因为我们会有节点非常多的情况,并且对节点进行拖拽,对整个视口进行缩放这种频繁的操作,GraphView的渲染模式无疑更占优势。

那么当前比较流行的节点编辑器有哪些呢?

开源的有

Node_Editor_Framework:最早期的节点编辑器之一,我自己Moba项目的技能编辑器就是使用它来制作的,但是历史悠久一样为他带来了巨大的历史包袱,向下兼容导致的代码臃肿,功能冗余,不是很推荐了。

v2-d4133db6e124ae53a1ece659983b5521_1440w

xNode:算是比较现代化的一个节点编辑器了,一些设计理念和基建都比Node_Editor_Framework先进和完善的多,而且代码也比较清爽,比较推荐。

v2-b0f0734ebfa6b4dcda92e0209bd8f469_1440w

NodeGraphProcessor:前面两个节点编辑器都是传统的IMGUI绘制,而NodeGraphProcessor是基于Unity GraphView的,它享受所有GraphView的特性和基建,性能和轻便性皆为上乘,但是需要自己处理序列化相关的内容,接入Odin相比较于前两者要麻烦一些,但从官方的技术发展路线来看,NodeGraphProcessor是时代发展的必然结果,所以就决定是你了!

v2-7e12d17a3d184e775083f29d7930c5c8_1440w

NodeGraphProcessor架构分析

先来看看NodeGraphProcessor的运行架构,了解了之后才能更好的魔改

Odin接入

由于NodeGraphProcessor使用了Unity的默认方案进行序列化反序列化,所以有非常多类型不支持(Dic,HashSet等),这点几乎是致命的,因为在游戏业务中这些泛型集合是非常常用的。Odin就可以很好的解决这个问题。

需要特别注意的有几点

  • 我们要把一些原本继承自ScriptableObject的对象改为继承Odin的SerializedScriptableObject,否则序列化反序列化支持不完善

  • 弃用一部分SerializeField,Serializable,SerializeReference,ISerializationCallbackReceiver等System或Unity原生序列化相关Attribute和接口,他会与Odin的序列化冲突导致数据损坏,丢失等问题

  • 由于原仓库使用了SerializeReference序列化List<BaseNode>引用,这个特性会递归序列化所有引用字段,所以BaseNode中的一些集合字段例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [NonSerialized]
    public NodeInputPortContainer inputPorts;
    [NonSerialized]
    public NodeOutputPortContainer outputPorts;
    [NonSerialized]
    internal Dictionary< string, NodeFieldInformation > nodeFields = new Dictionary< string, NodeFieldInformation >();
    [NonSerialized]
    internal Dictionary< Type, CustomPortTypeBehaviorDelegate> customPortTypeBehaviorMap = new Dictionary<Type, CustomPortTypeBehaviorDelegate>();
    Stack<PortUpdate> fieldsToUpdate = new Stack<PortUpdate>();
    HashSet<PortUpdate> updatedFields = new HashSet<PortUpdate>();
    //////////////////////////////////////////////////////////////////////

    依旧会被作为引用序列化,但如果使用Odin序列化方案,这些内容并不会被序列化并且在进入PlayMode后会进行GC置空,但好在这些字段都是实时计算的,所以我们只需要在BaseNode初始化的时候对这些字段进行初始化即可。

步骤主要为(具体修改内容参见此Commit:https://github.com/wqaetly/NodeGraphProcessor/commit/2a68396239d92773e827a63c0c44efb5383cccfe ):

  • 修改BaseGraph继承SerializedScriptableObject,并去除其Serializable特性,达到Odin托管序列化的目的,修改OnBeforeSerialize()和OnAfterDeserialize()为SerializedScriptableObject中的保护虚函数重写版本,去掉为BaseGraph编写的CustomEditor(GraphAssetInspector),使其使用Odin的Inspector面板

  • 修改BaseNode,去除其Serializable特性,达到Odin托管序列化的目的,在Initialize函数中进行字段的初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public void Initialize(BaseGraph graph)
    {
    this.graph = graph;
    ExceptionToLog.Call(() => Enable());
    inputPorts = new NodeInputPortContainer(this);
    outputPorts = new NodeOutputPortContainer(this);
    nodeFields = new Dictionary<string, NodeFieldInformation>();
    customPortTypeBehaviorMap = new Dictionary<Type, CustomPortTypeBehaviorDelegate>();
    InitializeInOutDatas();
    InitializePorts();
    }

    修改UpdatePortsForField函数,初始化fieldsToUpdate和updatedFields字段

    1
    2
    3
    4
    5
    public bool UpdatePortsForField(string fieldName, bool sendPortUpdatedEvent = true)
    {
    bool changed = false;
    fieldsToUpdate ??= new Stack<PortUpdate>();
    updatedFields ??= new HashSet<PortUpdate>();
  • 去掉为NodeInspectorObject的CustomEditor(NodeInspectorObjectEditor),因为NodeGraphProcessor本身使用了一种奇淫方法来绘制Node到Inspector上:当选中Node时,会查找其中是否有被ShowInInspectorAttribute标记的字段,如果有的话,就将这个节点加入NodeInspectorObject的selectedNodes中,并且将Selection.activeObject设置为NodeInspectorObject达成绘制的目的。当然如果想要所有Node都可以被Odin绘制在Inspector的话也非常简单

    1
    2
    // if (showInInspector != null)
    _needsInspector = true;
  • 根据个人需要做一些其他修改,大家要多注意测试从Editor进入PlayMode之后Graph的变化,很有可能序列化方式不对导致数据被GC产生报错。

我的NodeGraphProcessor版本

我Fork了一份NodeGraphProcessor,并且接入了Odin插件,供参考

https://github.com/wqaetly/NodeGraphProcessor

v2-a623479b007ad7352fb64c3e3eae513c_1440w

img

更多

其实上面那些操作也只是完成了一个可视化节点编辑器而已,而更加具体的业务比如技能编辑器,任务编辑器的数据导出等操作,因为不具备太强的通用性,大家可以自行实现

可以参考一下我之前的一个知乎回答:如何实现一个RPG游戏中的对话树系统?

参考

Mert Kirimgeri的GraphView教程

NodeBasedDialogueSystem

Built for performance: the UIElements Renderer – Unite Copenhagen 2019

文刀秋二的IMGUI相关知乎回答

Unity UIElement文档

Node_Editor_Framework

xNode

NodeGraphProcessor

如何实现一个RPG游戏中的对话树系统?