行为树概念
行为树(behavior tree)的概念最早来源halo这款游戏里的ai控制结构,它通过类似于决策树的树形决策结构来选择当前环境下应该做出的具体行为。由于这种ai控制结构在配置、调试、复用之上的便利,行为树的使用也逐渐成为了现在游戏的主流ai配置方式。unreal现在自带了行为树功能,而unity也有很多行为树相关的插件。下图就是unreal中配置完成的一个简单的行为树结构 :
行为树样例
下面我们来简单解释一下这颗行为树的功能。unreal的行为树的执行流是从上到下,从左到右,每个节点执行之后都会有相应的返回值true
或者false
, 返回之后控制权转移给当前节点的父节点,来确定下一步的执行:
- 行为树在执行时的第一个入口是ROOT节点,所有的行为树都会有此root节点, 当root节点执行完成之后,会重新开始一次执行,类似于无终止条件的循环。
- 进入root节点之后,进入Ai State这个Selector节点,依次从左到右执行他的三个子节点,当任一节点执行返回true的时候则不再执行后续的子节点,直接返回true给父节点。这个节点的逻辑就是让被控制的Entity进入追逐玩家状态还是进入巡逻状态。
- chase player节点是一个sequence,这个节点被一个decorator节点修饰,导致只有在
has line of sight
返回true
的时候才能进入执行,如果decorator返回false
则执行流回到Ai State
节点。它的三个子节点会从左到右依次执行,用来控制Entity去追打玩家的具体步骤: Rotate to face BB entry
,朝向目标BTT_ChasePlayer
设置自己进入追击状态,追击速度为500Move To
,移动到敌人位置
所以这个状态内,被控制的Entity会首先朝向敌对目标,然后设置自己为速度500的追逐状态,追逐到目标之后,返回true
, 如果其中任意一个节点返回false
,则后续子节点不再执行,当前节点也返回false
。
Patrol
节点是一个不带decorator
的Sequence
,他执行的时候也是从左到右执行三个子节点:BTT_FindRandomPatrol
设置自己为以自己为中心的随机巡逻状态,巡逻速度为125,巡逻半径为1000,获取半径上的一个随机点Move To
移动上一个节点确定的位置Wait
等待4-5秒
所以这个Patrol
的执行内容就是,以125的速度走到以自身为中心的半径1000的圆的任意一点,走到之后等待4-5秒,然后返回。
- 最后的等待1s是为了两个状态都无法进入的时候的
fallback
,避免root
节点空跑占用cpu
。
行为树定义
整个行为树就类似于我们写的一个函数调用,他的树形结构就类似于传说中的图形化编程。一颗行为树最终运行时,还依赖于他的执行环境,例如范围内有没有目标就可以让这棵样例行为树控制的Entity呈现出不同的表现。这些行为树的运行环境,我们可以抽象为一个key value
的容器,叫做黑板Blackboard
, 代表行为树的内部存储的所有参数。当一颗行为树在特定的黑板环境中运行时,行为树的控制权不断地在树形结构中转移,类似于程序计数器Program Counter
。运行时某一特定时刻的拥有控制权的节点集合则定义为行为树的格局Configuration
。
因此一颗行为树的运行时描述,包括如下三个方面:
- 行为树的自身结构,所有节点的逻辑关系和相关参数:
Structure
- 行为树的执行环境,一个键值对的集合:
Blackboard
- 行为树的活动节点集合:
Configuration
行为树的结构是以树的形式来组织的,每个节点都有相印的节点类型,配合相关参数承载不同的功能。在上面的行为树样例中,我们可以对相关的节点进行归类:
- 黑色的调度控制节点(
composition node
),包括Root
,Patrol
,Chase Player
- 紫色的行为表现节点(
action node
), 包括Rotate to face BB entry
,BTT_ChasePlayer
,Wait
,Move To
,BTT_FindRandomPatrol
, 他们可以当作函数来调用,通过传入特定的参数去调用这些行为节点,来完成特定任务。 - 蓝色的修饰器节点(
decorator node
),用来作为特定节点的前置条件。
上面的这几类节点类型都是来自于unreal,在不同的行为树之中,对于节点的划分也是各有不同。总的来说,一个行为树的结构描述都具有如下几个部分:
- 行为树是一个树结构,根节点就是root节点,作为行为树的入口,节点类型为
Root
,每个行为树有且只有一个Root
类型节点; - 所有的叶子节点的类型一定是
Action
,同时Action
类型的节点一定不能作为非叶子节点来使用。 - 非叶子节点也称为组合节点
Composition
,可以有一个或多个子节点,root
节点一定只有一个子节点 Action
节点类型和Composition
的节点类型可以做进一步的细分- 每种类型的的组合节点能拥有的子节点数量与节点类型有关
- 一个节点的所有子节点是一个有序列表
- 有些节点可以附加特定参数来执行,有些节点则不需要参数
- 一颗行为树可以以叶子节点的形式被另外一颗行为树进行调用,就相当于一棵树挂接到了另外一棵树上一样
行为树的运行
在明确了行为树的定义之后,行为树的控制表现还依赖于它在特定环境下的执行路径。为了推理行为树的执行路径,我们需要对行为树的运行规则做规定。这里我们把一个节点标记为N
, 他的子节点列表标记为N.sons
,第i
个子节点为N.sons[i]
,他的父节点标记为N.parent
, 节点的运行标记为N.run()
,运行完成之后返回true
或者false
,代表节点执行成功或者失败。
对于Action
节点来说,因为他是叶子节点, 不带控制功能,所以他是不影响执行流的。能影响执行流的节点只能是Composition
节点。在具体的行为树节点类型定义中,常见的Composition
节点细分见下:
Sequence
节点,他的执行流程就是顺序执行所有的子节点,当一个子节点执行结果为false
的时候终止执行并返回false
,如果没有子节点返回false
则返回true
。他的run
函数定义如下:
bool run()
{
for(std::uint32_t i = 0 ;i < sons.size(); i++)
{
if(!sons[i].run())
{
return false;
}
}
return true;
}
Select
节点,他的执行流程就是顺序执行所有的子节点,当一个子节点执行结果为true
的时候终止执行并返回true
,如果没有子节点返回true
则返回false
。他的run
函数定义如下:
bool run()
{
for(std::uint32_t i = 0 ;i < sons.size(); i++)
{
if(sons[i].run())
{
return true;
}
}
return false;
}
IfElse
节点,他拥有三个子节点,当第一个子节点返回true
的时候执行第二个子节点并返回此子节点的返回值,否则执行第三个节点并返回这个节点的返回值。他的run
函数定义如下:
bool run()
{
if(sons[0].run())
{
return sons[1].run();
}
else
{
return sons[2].run();
}
}
While
节点, 他有两个子节点,当第一个子节点执行返回true
的时候,执行第二个子节点然后重新开始执行流程,如果第一个子节点返回false
则执行完成,并返回true
。他的run
函数定义如下:
bool run()
{
while(sons[0].run())
{
sons[1].run();
}
return true;
}
Root
节点,他只有一个子节点,当子节点返回的时候,返回子节点的返回值:
bool run()
{
return sons[0].run();
}
Probility
节点,他根据内部存储的权重参数,每次执行时随机选择特定子节点执行,并返回执行结果:
bool run()
{
std::uint32_t idx = random.choice();//根据权重选择节点索引
return sons[idx].run();
}
针对Sequence
类型,还有一些衍生类型:
AlwaysSequence
, 顺序执行所有子节点,不管子节点的返回值,执行完所有子节点之后返回true
;RandomSequence
, 每次执行的时候都以随机序列去执行所有的子节点,任一子节点在执行时返回false
则中断执行并返回false
,否则返回true
在上面的节点类型分类中,我们并没有看到decorator
节点的定义,但是我们可以通过Sequence
节点模拟出来,只需要将decorator
里面的判断函数作为Action
节点去执行,对于被任意修饰器修饰的节点都可以转换为含有修饰器判断节点和具体执行节点的Sequence
节点进行替换。在Unreal
中,他优先采用decorator
方式的理由如下:
在行为树的标准模型中,条件语句是任务叶节点,除了成功和失败,它不会执行任何其他操作。虽然没有什么可以阻止您执行传统的条件语句任务,但是强烈建议使用我们的装饰器(Decorator)系统处理条件语句。
使条件语句成为装饰器而非任务有几个显著的优点。
首先,条件语句装饰器使行为树UI更直观、更易于阅读。由于条件语句位于它们所控制的分支树的树根,如果不满足条件语句,您可以立即看到行为树的哪个部分是“关闭的”。而且,由于所有的树叶都是操作任务,因此更容易看到行为树对实际操作的排序。在传统模型中,条件语句位于树叶之间,因此您需要花费更多的时间来确定哪些树叶是条件语句,哪些树叶是操作。
条件语句装饰器的另一个优点是,很容易让这些装饰器充当行为树中关键节点的观察者(等待事件)。这个功能对于充分利用行为树的事件驱动性质至关重要。
行为树的驱动方式
在标准行为树中,节点的运行是由tick-driven
的,每间隔一段时间开始从root
节点开始执行。当特定外部事件需要响应的时候,有时会按需调用root
节点的执行。由于这个行为树在执行的时候,对于上次的执行结果是无记忆的,所以Entity
的状态机要处理好各种追击、攻击、受击、巡逻状态的强制切换,避免表现异常。最坏情况下一次执行会遍历所有的节点,如果tick
间隔过小,则行为树执行会消耗大量cpu
。同时如果一段时间内执行的路径结果都相同,行为树就相当于空跑浪费cpu
。所以在标准行为树模型里面,如何动态的选择tick
间隔是优化的重点。
为了解决这种tick
间隔带来的问题,行为树的模型演进出了基于事件驱动(event-driven)
的行为树。这里行为树的更新不再是基于tick
,而是基于任务的完成和外部事件的trig
。同时每个Action
节点开始有了状态,他的执行可能不再是调用之后立即返回,而是开始了一个需要一定时间才能执行的过程,当过程执行结束之后才返回执行结果。同时,任意的一个过程现在都需要支持中断操作,以支持外部环境的改变引发的更高优先级任务的处理。
以追逐目标这个例子来说:
- 在
tick
驱动的行为树中,我们需要定期从根节点执行,查询我们是否已经离目标点足够近,如果足够近则执行已经到达目标的分支,否则执行追逐逻辑。到发起追逐到追逐完成期间,可能多次执行行为树。 - 在事件驱动的行为树中,一旦进入了
Move To
节点,则会发起一个寻路过程,同时节点标记为running
状态。在寻路到目标之后过程返回,控制权移交到当前节点的父节点,然后进行下一步的操作。一个完整的流程不涉及到行为树的其他节点,相对tick
驱动的行为树来说,行为树的决策消耗大大降低了。
在寻路过程中,目标可能已经死亡或者传送了导致目标丢失,此时我们需要终止当前过程的执行。在事件驱动的行为树中,为了实现对外部事件的响应功能,常见的可选方案有如下两个:
- 为过程添加前置条件,在过程执行期间定期检查前置条件是否满足,如果不满足则中断当前过程的执行并返回
false
。 - 为行为树添加
Parallel
节点和WaitEvent
节点, Parallel
节点执行时,会顺序执行所有的子节点,而不会阻塞在子节点的过程调用上,如果任一子节点返回,则所有的其他节点都会被打断, 同时Parallel
节点返回true
。为了支持这个结构,我们需要对原有的行为树调用结构进行修正,因为这里暂时不再给出他的run
函数定义。WaitEvent
节点执行时,会注册对特定事件的回调,然后阻塞不返回。当行为树接收到特定事件之后,对应的回调句柄被调用,相关的WaitEvent
节点返回true
。
通过在Parallel
节点下同时挂载多个节点,就可以达到在执行特定过程的时候对外部事件进行响应的功能。
在Unreal
和本人所在的项目组都采取的是Parallel
方案,但是引入Parallel
方案也带来了新的问题,就是策划可能配置出多个过程同时进行的Parallel
节点。试想一下同时开启两个对不同目标点的寻路所带来的后果,Entity
的状态表现会非常的糟糕。Parallel
结构里面不能同时开启多个持续性过程,一般来说是一个主要目标过程附加一些WaitEvent
或者WaitTimer
的阻塞过程,这些附加的阻塞过程不会干扰主要目标过程,他们的执行也是一些辅助性的工作。
所以在Unreal
中,特别提到了Simple Parallel
节点。
简单平行节点只有两个子项:一个必须是单个任务节点(拥有可选的装饰器),另一个可以是完整的子树。可以将简单平行节点理解为“执行A的同时,也在执行B"。例如“攻击敌人,同时也朝敌人移动。“从基本上而言,A是主要任务,B是在等待A完成期间的次要任务或填充任务。
行为树与状态机
但是如果遇到了需要终止当前主要任务的事件,则Parallel
结构也是不够用的。例如在巡逻过程中遇到敌人,我们需要立即进入战斗状态,此时需要中断当前任务的执行来开启新的任务。类似的还有在不断的放技能过程中如果发现自己的血量低于了特定百分比则进入狂暴状态。为了处理这种紧急事件的打断,我组的实现方案是在行为树的上层加一个状态机来进行管理。
状态机有一个默认状态,在每个状态中执行特定任务的行为树,同时状态与状态之间有一个基于事件的跳转表。当Entity
的AI
接受到一个外部事件的时候:
- 当前状态所执行的行为树优先处理这个事件,查看当前阻塞的
WaitEvent
的节点是否有对此事件的监听。如果有,则行为树来处理这个事件 - 如果行为树没有对这个事件进行处理,则状态机来查看当前状态下是否有对于这个事件的新状态跳转。如果有对应的跳转,终止当前行为树的执行,跳转到对应的状态,开启新状态下的行为树的执行
在本项目,对于一个小怪来说,他一共有三种状态:
patrol
状态,用来处理怪物的巡逻,这个是状态机的默认状态fight
状态,用来处理遇到敌人的战斗return
状态,用来处理战斗结束之后的处理,例如先回到出生点,然后重置AI
来重新以默认状态开始执行
在这个小怪的跳转表,主要处理两个事件enter_battle
和exit_battle
:
- patrol状态遇到enter_battle事件切换到fight状态
- fight状态遇到exit_battle事件切换到return 状态
这样一个基本的小怪AI
就配置完成了,最后先预告一下自己基于Qt5
写的行为树编辑器,项目地址 https://github.com/huangfeidian/behavior_tree :
下一篇文章来讲行为树的相关工具,包括编辑器、运行时、代码预处理器和调试器,估计周末才有空更新。