DIY系列:在C++中自己实现可视化脚本系统

发表于2015-12-15
评论2 9.5k浏览

DIY系列:在C++中自己实现可视化脚本系统

前言:

使用可视化脚本语言进行游戏开发,虽然不算当下主流,但由于其简单、直观、易学等特点,让不少非专业游戏开发者也能够胜任程序开发的任务,大大地降低了游戏开发的门坎;而在专业的游戏开发过程中,可视化脚本(广义)也在逐步体现出其价值,比如使用节点式编辑的形式来进行材质的开发,带有逻辑功能的动画混合树的编辑(Unity5、Morpheme),以及Unreal 4引擎中引入的涵盖了所有放方面面的Blue Print脚本和Unity的著名组件PlayMaker等,使得开发团队中的策划和美术在不需要程序进行重度开发的前提下,也能进行多方面的设计和验证(尽管通常存在着一些性能上的问题)。

2014年的下半年,我们开发了一款用于可以用于技能、过场动画、AI逻辑等开发的Unity可视化脚本组件AgeAction2(链接)。这个组件继承了AgeAction一代非线性编辑的优点,并脑洞大开地将状态机、流程图的可视化节点式编程风格与非线性编辑结合起来,使其既能胜任技能和过场动画等编辑,又具有强大的逻辑编辑能力,能够进行角色AI、关卡逻辑等的制作。但是AgeAction2仍未完成,在实际使用中,暴露出了一些兼容性和性能问题,使其未能像AgeAction一代那样被各个项目广泛使用。进入到2015年,随着研发部的转型,我们的主要工作转向了游戏项目的开发,导致没有时间对AgeAction2进行改进完善。作为主要的设计者和开发者,我深信可视化脚本会给游戏开发者带来极大便利,决定在业余的时间中(没错就是在家里),将可视化脚本以及基于可视化脚本的诸多功能作为个人研究的一个课题,于是便有了这篇文章。

 

目录:

1、各种类可视化脚本的介绍

2、目标可视化脚本系统描述

3、实现

1.反射与序列化

2.层次状态机系统(Hierarchical State Machine)

3.流程图与节点

1)         端口(Pin)

2)         节点(Node)

3)         流程图(Flow Graph)

4)         参数、变量与消息

5)         类型转换

6)         时间与速度(Timeline)

7)         节点库

4.行为(Behaviour)

5.与Entity Component系统的整合

6.编辑器

7.优化与扩展

1)         Pooling

2)         可视化调试

 

1、各种类可视化脚本的介绍

可视化脚本作为一个广义概念,其形式和实现方式是多种多样的,而各种不同类型的可视化脚本皆有其适用的方向。这里我们从表现形式与实现方式这两个方面来看一下现今各种奇奇怪怪的可视化脚本:

       表现形式上

         基于节点
例子:Blue Print(UE4)、Kismet(UE3)、uScript(Unity);以及非游戏引擎的Max材质编辑器、World Machine等。
基于节点的可视化脚本是最常见且最广泛使用的,其重心思想是,将构成脚本逻辑的最基本、最简单的功能定义成节点,每个节点就像是一个简单的函数,可以有一到多个输入及输出——通过输入输出的关系将大量节点连接起来(确立执行顺序和依赖关系),就能表达复杂逻辑。

优点

       自由度高:节点都是原子功能,没有限定死的模式和规则,只要满足输入输出的规律,就可以构成任意逻辑;而大量节点的组合搭配是爆炸式的,理论上可以满足任何功能和逻辑的需求。

       逻辑性强:节点的连接是基于执行顺序和依赖关系的,整个脚本逻辑的任何细节对于开发者来说都是白箱,编写出来的脚本易于深入理解和调试。

       可视化调试:节点式编辑的界面,本身就非常适合进行可视化调试;一个优秀的可视化调试方案,可以在任意节点处进行断点,显示每个节点的执行情况和输入输出,甚至进行录像回放,这对于游戏开发者来说非常有帮助。

缺点

       可读性问题:由于节点都是原子功能,所以一个复杂的逻辑可能需要几十甚至上百个节点相互关联来实现,这直接导致了程序的可读性大打折扣。幸运的是,有不少方法可以规避这个问题,AgeAction2里面实现的自定义事件、段落、注释等功能就在一定程度上让复杂程序变得易于理解。

       编写麻烦:与可读性问题类似,复杂功能需要摆放和连接大量的节点,导致编辑复杂程度上升——这个问题在处理数学公式时候尤为突出,比如z=(x^3+y+1)*2这样的简单公式往往需要使用6个左右的节点。这个问题可以通过适当地利用文本公式或脚本语言的解析来简化(见三.7(3)节)

中性点

       扩展开发的复杂度:当一个功能无法由当前的节点库实现的话,就需要开发新的节点的功能,而开发的复杂度视情况而定:如果当前的节点库已经涵盖了足够多的基本操作,一个新的功能可能只需要编写一个新的节点或者修改一下已有节点便可实现;在另一些情况下,一个功能很可能需要编写一系列的新节点才能实现。

       可能的性能问题:在一些实现下面,节点之间需要进行大量的数据传递(拷贝),以及节点储存自身状态需要额外的内存开销;而在另一些实现下面则不存在这些问题。

图 1 UE4的Blueprint脚本系统

 

         基于行为(Behaviour
例子:Play Maker(Unity)、Animator Behaviours(Unity5);以及非游戏引擎的LEGO机器人EV3开发套件等
基于行为的可视化脚本和基于节点的可视化脚本的最大区别在于提供功能的粒度上——与节点的原子化功能不同,一个行为(Behaviour)通常包含了完整的逻辑,并且通常可以独立地完成设计好的复杂功能。节点式的脚本需要用户去关注如何通过组合节点来实现功能,基于行为的脚本用户则一般只需要关注行为本身而非其内部实现。基于行为的脚本从本质上来说就是一个Entity Component系统,行为其实就是Component,而Entity的职责通常由状态机或行为树的节点来扮演,以强化行为之间的逻辑关系。行为与行为之间基本是不耦合的,独立性强,多数时候只存在执行顺序关系,而没有依赖关系。

优点

       使用简单:单个行为即可表达完整逻辑或复杂功能,内部逻辑对用户来说透明;同时,编写脚本的工作量大大缩小

       可读性强:与复杂的节点式脚本不同,行为的功能通常非常直观,甚至不需要特别编写注释,阅读起来不会有困难

       性能通常较好:行为一般是由代码直接实现的,不需要进行大量的数据传递,内存开销也较小

缺点

       自由度低:行为的特点决定了其不方便通过相互组合来实现更高级的功能,当行为库中没有提供想要的功能的时候,通常只有编写新行为(而且是写代码)一条路可走,这带来了不小的二次开发量。

       调试麻烦:除非系统提供了源码,不然行为内部的逻辑对于用户来说就是黑箱;即便是拥有源码,行为也无法进行像节点那样深入的可视化调试。

中性点

       适用范围的问题:基于节点的脚本可以轻易地套用到除了实现某种行为(字面义)以外的其他领域,比如用于表达一个公式的输入输出,或是构成动画混合树;而基于行为的脚本则一般只能用来表达某种行为(字面义)。

图 2 Playmaker的状态机系统(左视图),以及状态上挂载的行为(右视图,在Playmaker中行为称为Action)

       实现方式上

         解析执行
例子:Blue Print(UE4),Play Maker(Unity)
解析执行的脚本,通常使用自定义的格式或是XML等通用格式来储存脚本逻辑,执行时直接将脚本载入到内存中,进行Parse后,调用相应的代码来按顺序执行其中节点或行为。

         生成代码
例子:uScript(Unity)
这类脚本通过将多个节点的组合逻辑转换为一个或多个函数,并最终生成目标环境所能编译的代码文件。通常只有节点式的脚本使用这种方式。

        对比

       性能上,由于生成代码方式可以在代码层面上进行多种优化(例如把函数调用扁平化),同时避免了节点之间的数据传递和拷贝,所以一般生成代码的性能会优于解析执行。

       解析执行的脚本由于无需编译,可以在修改后直接执行,甚至可以边执行边修改;而生成代码的脚本这一点就稍微麻烦,特别是生成C++代码的情况下。

图 3 可视化脚本与相应的代码

 

2、目标可视化脚本系统描述

说了这么多,我究竟打算做一个什么样的可视化脚本系统呢?一切从需求出发,先来看看我希望这套脚本系统能用来做一些什么样的事情:

         角色AI控制

         关卡逻辑

         场景动态物件

         过场动画

         角色动画状态机

 

将需求对应到相应的特性上:

需求

层次状态机

行为树

基于节点

基于行为

解析执行

生成代码

角色AI控制

关卡逻辑

场景动态物件

ü

ü

ü

ü

ü

ü

过场动画

ü

ü

ü



角色动画状态机与混合树

ü

ü



ü



1 由于需要频繁的微调及修改,生成代码的方式显得工作效率低下

2 受到Morpheme的启发(参见这篇文章),基于节点的编辑非常适用于动画状态机及混合树的编辑,能够覆盖到更多功能总是好的(参见我的下一篇文章《DIY系列:在C++中自己实现动画系统》)

3 动画混合树有明确的输入输出依赖关系,显然必须是基于节点的

 

综上所述,我的目标可视化脚本系统将是:基于节点和层次状态机系统,通过解析执行来运作的可视化脚本

3、实现

1.     反射与序列化

在我的前一篇文章(《在C++中自己实现反射与序列化》)中,我详细介绍了如何通过宏与静态函数的使用来在C++中实现一套灵活易用的反射和序列化机制,现在这套机制马上就会派上用场。在我的这套可视化脚本系统中,如下几个方面会用到反射与序列化:

         文件格式储存
整个可视化脚本将使用序列化功能自动存储为文本或者二进制格式,只需要编写很少量的存储读取代码,同时文本格式还支持Merge

         对象类型的动态识别与类型转换
可视化脚本和其他脚本一样,会涉及到各种不同类型的对象(参数、变量等),反射功能可以动态进行对象类型的识别和进行统一的类型转换。

         对象拷贝、实例化
同一个脚本在执行时候会存在多个实例,这些实例都是由资源模板实例化拷贝出来的,使用序列化功能可以不用编写额外的代码就能实现对象的拷贝;另外,编辑器的复制、粘贴、撤销等功能都可以采用类似的方法。

         枚举对象的成员属性和方法
通过反射功能,可以将系统中任何可反射类的参数和方法映射成可视化脚本中的节点,不仅减小了节点的实现工作量,也试可视化脚本的适用范围更广。

2.     层次状态机系统(Hierarchical State Machine)

首先我们来实现一套层次状态机系统。状态机(有限状态机FSM)想必大家都不陌生。详细的说明可以看wiki(链接)。简单来说,在程序范畴中,一个状态机(State Machine)包含多个状态(State),状态如其字面意义,表达特定时刻下系统的状况和正在执行的任务。在一个状态机之内,任意时刻有且只有一个状态是激活的,而两个状态之间存在转换(Transition),允许在条件满足的情况下,激活态从一个状态变换到关联的下一个状态。通常,一个状态被激活的瞬间、激活中每一次帧更新,取消激活的瞬间,以及其他一些事件,会调用指定的回调方法。而在层次状态机系统(Hierarchical State Machine)中,一个状态有可能是一个子状态机。

开发者在使用状态机时,要做的事情有:

  1. 决定有哪些状态(以及子状态)
  2. 决定有哪些转换,以及转换的条件
  3. 定制状态的各个回调函数,以实现状态机的具体功能

图 4 一个角色的状态机

介绍完毕,我们来看具体的数据结构:

层次状态机组成树状结构:

         当状态机被激活,Enter方法会被调用,并递归激活默认子状态(如果有的话);然后检查子状态见的转换,如果条件满足则发生转换,当多个转换同时满足时,执行优先级最高的。

         当状态机在每一帧被更新时,Update方法会被调用,并递归更新当前激活的子状态;然后检查子状态见的转换,如果条件满足则发生转换,当多个转换同时满足时,执行优先级最高的。

         当转换发生时,转换的源状态被取消激活,目标状态被激活,并调用转换的DoTransition方法;然后马上执行下一次转换检查,如果条件满足则发生转换,知道到达单帧最大转换上线为止(防止死循环)。这么做的好处是能够最大限度满足转换的实时性需求。

         当状态被取消激活时,先递归取消激活当前激活的状态,然后Exit方法会被调用。

在大多数状态机组件中,都存在AnyState转换这么一种东西,这种转换不明确指定源状态(值为NULL),而动态地把当前激活的状态当作源状态(事实上,就是任何状态都能转换到指定的目标状态),用于处理一些普遍存在的转换状态,大大地减少了工作量,增加了可读性。

图 5 (左)所有状态都能够转换到Target_State (右)使用AnyState转化来简化编辑

3.     流程图与节点

从上面的代码可以看出,当状态机的Enter、Update、Exit、OnMessage和转换的CheckTransition和DoTransition方法被调用的时候,都会去调用流程图(FlowGraph)来执行具体的状态机逻辑。事实上,FlowGraph才是整套可视化脚本的核心,用户基本通过自定义流程图来实现整套脚本的功能。流程图,如其字面意义一样,就是依照某种顺序,依次执行指定任务的数据结构。具体来说,在基于节点的可视化脚本中,流程图决定了有哪些节点,以及这些节点以怎样的执行顺序和依赖性相互连接,可以说流程图才是脚本的主要部分。一个标准的流程图例子如下图:

图 6 一个流程图示例,描述了当条件满足时创建多个对象并存入列表的逻辑

 

在上面所示的流程图中,包含了流程图中节点执行的基本情况:

         流程图包含节点,每个节点实现简单的功能,如上图的Log节点实现输出调试信息功能,CreateEntity节点实现创建游戏对象功能

         流程图有唯一入口,该流程图从入口节点开始执行,如上图的OnEnter节点

         流程图中的所有节点相互连接以确定执行顺序和参数依赖顺序,有两种形式的连接:

         执行顺序连接(ActionLink),以三角形和线条表示,含义是左边节点执行完毕,右边节点开始执行

         参数依赖连接(ParamLink),以圆圈和线条表示,含义是右边节点的执行参数需要从左边节点的输出获得,圆圈中心的颜色标识参数的数据类型

         流程图中会有动态分支和循环发生,如If节点会根据传入的参数选择执行分支,Loop节点会根据传入参数重复地执行后续节点(上图中的CreateEntity与Push节点)

         流程图中可以获取状态机的变量值,如上图的[Enabled]、[Template]、[List]三个节点分别表示来自流程图所属状态机的三个变量,这些变量的值可以由外部设置,以此来干涉流程图的逻辑

 

如何实现流程图的这些功能点呢?从先从节点与端口的定义出发。

1)       端口(Pin

图 7 一个普通节点例子

 

节点从结构上来说,是一个端口(Pin)容器。所谓端口,是指节点与节点之间相互连接的接口。上文提到,节点之间有执行顺序连接与参数依赖连接两种关系,因而端口也分为执行端口(ActionPin)与参数端口(ParamPin)两种,它们都继承于一个基类FlowGraphPin。

参数端口比较特殊,它实现了一个Variant类,可以储存任意类型的值。这里我们使用反射功能来实现,使用MetaInfo来记录值类型、创建和销毁值,以及实现自动序列化和反序列化各种类型的值。

 

端口有两个关键属性:输出(output)与回溯(backtrack),皆为bool型:

         输出

         若为true,表示端口处于节点的右边:当端口为执行端口时,表示节点的逻辑从该端口处执行完毕而转入关联的后置节点;当端口为参数端口时,表示关联的后置节点将从该端口处获取(拷贝)所需参数的值

         若为false,表示端口处于节点的左边:当端口为执行端口是,表示节点的逻辑从该端口处开始转入执行,相当于是调用了该节点的一个函数;当端口为参数端口时,表示该节点的一个参数,节点的执行会依赖到这个端口上储存的参数值,若与其他参数端口关联,节点执行时该参数值将自动从关联的前置端口处取得

         回溯,只适用于【输出执行端口】与【输入参数端口】:

         若为true,表示当执行逻辑从该端口处转到关联的其他节点,则过后的某一时刻还会返回当前结点继续执行,称为回溯。对于执行端口,若需要实现循环逻辑(如Loop、DoWhile),则需要标记为回溯端口;所有输入参数端口都是回溯端口,因为最终都要回到需求参数的节点继续执行。

         若为false,则表示该执行逻辑从端口(只可能是执行端口)转入下一个节点后,将不会再返回当前结点继续执行,这时相当于程序上尾调用的概念。大多数顺序执行的程序,使用的都是非回溯端口。

图 8 流程图中回溯端口分析

 

另一方面,端口的前置与后置关联端口有数量上的限制:

         对于执行端口而言,可以有任意数量的前置与后置关联端口:

         前置执行端口表示,该节点会被不同前置关联节点分别调用执行多次

         后置执行端口表示,该节点执行完后,会依次地执行完后续关联的每一个分支

图 9 前置和后置关联多执行端口的情况举例

 

         对于参数端口而言,只能有最多一个前置关联端口,但可以有多个后置关联端口:

         单一前置关联端口保障了节点的一个参数只依赖于一个前置节点,否则会产生多义性

         多个后置关联端口表示该节点输出的一个参数可以被多个后置节点所引用

 

图 10 前置和后置关联参数端口的情况举例

 

2)       节点(Node

了解了端口的行为后,我们便可以来定义节点:

所有节点,都必须事先节点类最重要的两个方法:ConstructNode与Trigger,这里重点说明一下Trigger方法:

Trigger方法包含一个节点的执行逻辑,当前置节点执行完毕,通过执行端口调用了当前结点,或是当后置节点需要从当前结点取得参数值的时候,Trigger方法就会被执行。

注意节点的执行不一定一次完成,如果节点包含回溯端口(比如依赖于某些参数,或是有循环逻辑),则一个节点的Trigger方法在一次流程图执行中会被调用多次。Trigger方法的几个参数与返回值的含义是:

         _enterPin:当前结点本次被调用的的入口端口,可以是执行端口或是参数端口,节点实现经由这个参数确定被调用的是节点的哪个逻辑分支

         _backtrackPin:当前结点本次被调用的回溯端口,这个参数仅在节点执行经由回溯逻辑回到当前结点的时候才不为NULL,而是上一次调出的端口,节点实现经由这个参数来确定节点逻辑当前的执行进程

         _logger:用于输出log到控制台

         _timeInfo:包含时间信息,globalTime、localTime及deltaTime

         返回值:与接下来要执行的其他节点相连接的端口(比如需要参数时,返回参数端口),用于控制执行的走向

 

上面的描述其实比较抽象,以下我们以两个常用节点:Log与IntAdd为例子来详细说明节点的实现。

图 11 Log与IntAdd节点各个端口含义

         Log节点

用于打印普通Log、警告或者错误信息到控制台,需要输入Log文本和分类文本,Trigger函数体如下:

         IntAdd节点

用于两个整数的相加,这个节点属于运算符节点,没有执行端口,它的执行总是以参数端口作为入口。Trigger函数体如下:

 

3)       流程图(FlowGraph

节点也实现了,现在就来看看如何实现流程图的逻辑。

首先,我们需要一个结构体来记录节点产生的回溯信息,这个结构体最终要放到一个栈中,起到类似函数调用堆栈的功能。

 

有了回溯栈,我们就能够描述整个流程图的执行顺序,以下图为例:

图 12 流程图执行示例

 

FlowGraph类的Execute方法实现了流程图的执行顺序控制

一个节点对下一个节点的调用,可以看成是一个函数对另一个函数的调用。Execute方法事实上是通过循环和栈来模拟了函数调用,同时又考虑到了正确的尾调用的问题——尾调用的回溯没有任何意义,因而尾调用不需要压栈,这样一来,一系列依次执行无需回溯的节点不会存在任何上下文的储存和恢复,节省了很多的时间和空间。

这也是对AgeAction2性能问题的一个反思,AgeAction2采用C#的delegate/event来实现节点之间的调用关系,看似很方便,但事实上一个很简单的流程图常常会有很深的栈,这导致可视化脚本执行的顺序低下。实现正确的尾调用,将函数调用扁平化、循环化是一个好的思路。

4)       参数、变量与消息

为了实现动态特性,流程图中经常需要引用外部的变量(这些变量最终来自于状态机)。一类特殊的节点,名为VariableNode,专门用于通过名字来引用外部变量,并支持读写操作。事实上,VariableNode并不直接从状态机取得变量引用,而是通过FlowGraph的一个特殊的GetVariables()接口来间接获得状态机的变量。这么做是为了方便今后实现自定义节点,即用户可以通过流程图来实现一个复合节点的功能,这个流程图需要有自己的变量列表,通过GetVariables()方法解除了与状态机的耦合。

有一类特殊的变量——消息(Message),消息其实就是维持一帧的整数类型,用于记录当帧某事件是否发生,以及发生的次数。状态机会在每帧更新开始时清空消息缓存。通过SendMessage节点可以发送消息,也可通过IfMessage节点判断是否收到消息。使用消息可以很方便地进行状态机转换的条件判断。

5)       类型转换

当相关联的两个参数端口的值类型不同(比如float与int)时,需要进行自动的类型转换。这可以通过实现一个类型转换管理器来完成。并不是所有的类型转换都能够通过强转来实现,事实上,在可视化脚本的使用过程中经常会遇到如下情况:

         Entity与Component互取

         父类型与子类型指针互转

         资源路径与资源实例互转

         字符串与数值互转

         数值间强转

         明确可互转的类型

利用反射机制中的MetaInfo信息,我们可以方便地判断两个参数端口的值类型,再通过建立一个Mapping信息库,我们就可以快速判断两个值之间是否能相互转换,以及如何相互转换。用户可以将自己的自定义类型,以及自定义类型转换器注册到系统中。

类型转换管理器提供两个方法:IsParamConvertable()与ConvertParam()方法:前者传入两个类型,返回理论上两个类型是否可以相互转换;后者传入两个值的引用,将前者进行转换后写入后者,并告知是否成功。当进行过一次转换后,参数端口可以将转换方法进行缓存,以提升后续转换的效率。

图 13 类型自动转换的例子,参数端口连线的颜色变化表示发生了类型转换

6)       时间与速度

回顾一下3.3.2节,会发现FlowGraphNode类的Trigger方法带有一个TimeInfo类型的参数,该类型是一个结构体,包含了globalTime(自根状态机激活后经过的总时间),localTime(自当前状态机激活后经过的时间),deltaTime(自上一次执行经过的时间)。通过GetTimeInfo节点可以将这些时间暴露到流程图中,如此我们便能够控制在特定的时间执行特定的节点,以及可以实现慢放快放效果等。

7)       节点库

一个方便易用的可视化脚本系统少不了丰富的节点库,对于日常使用和游戏开发而言,有这么几类功能的节点是必须实现的:

         数学库,广义的数学库应当包含整型、浮点数的计算、比较、位计算、逻辑功能、三角函数、2D、3D向量、四元素和矩阵等

         流程控制,包括If、Switch、For循环、ForEach循环、DoWhile循环等

         状态机控制,主要是触发状态机转换、发送消息等

         动画播放控制、IK等

         对容器类的操作

         时间相关

         游戏对象控制,创建、销毁、实例化Entity、添加Component、设置位置、旋转等

         关卡控制

         调试信息输出

         字符串相关

         判断类型以及判断NULL

         游戏引擎各模块的控制……

手动开发的各方面功能节点固然越丰富越好,但是仍然难以覆盖到一些细节。这时候,反射机制又派上了,通过枚举各个类反射的属性和方法,可以自动将其映射为节点,这样可视化脚本系统就拥有了对系统的基本所有对象的控制能力,实用性大大增强。

除此之外,还可以考虑对Lua等脚本或者公式进行解析,映射成节点来方便用户的使用,特别是能够应对基于节点的可视化脚本容易把简单公式搞复杂的问题(见图3)。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引