基于行为树的写实赛车AI
基于行为树的写实赛车AI
-----by amblerli
前言
在《天天飞车》最新版本《漂移生涯》里面,我们采用了行为树来开发写实赛车效果的AI。究竟行为树是什么,写实赛车AI的算法是怎样的?我们在这将进行探讨。
行为树的概念
行为树与状态机不同的是,行为树是一个树状结构,控制着AI的决策流程。对于树的最底层节点,就是真正执行的AI行为命令。每个节点可以有多个子树分支,通过自行选择并执行最优的行为分支,从而作出最优的AI决策。整个树每个节点都是封装好的程序脚本,开发者制作基础通用的节点库后,游戏设计师可以组装出任意形式的AI。行为树与决策树不同的是,它的一个节点可以是非常底层微小的操作,实际上行为树具备了决策树的功能。
行为树的执行
每次更新行为树叫作Tick,行为树的一个节点可能需要多个Tick才能执行完。每次遍历可以只执行一定数目的节点,下一次遍历直接在上次的地方继续。
行为树的节点种类
行为树的节点可以有多种类型,但不管哪种类型,它都返回下面三种结果。
Success
Failure
Running
前两种很好理解,第三种Running表示这个节点还没有执行完,需要下一次tick继续。比如,一个“移动到指定地方”的节点,仍在计算路径或人物仍在移动时会返回Running,当人物没法到达指定地方时,会返回Failure,当到达时返回Success。
节点最常见有以下三种类型。
Composite
Decorator
Leaf
Composite
Composite可以有多个子节点,它会按从左到右或者随机的顺序执行子节点。Composite一般会根据子节点的结果来返回结果,最常见的是Sequence,类似于 And,它要求每个子节点都返回Success才会返回Success;还有一种是Selector,类似于Or,只要任意一个为Success就会返回Success。
Decorator
Decorator只会有一个子节点,它会对子节点返回的结果进行转换,或者循环子节点的行为。最常见的是Inverter,它会把子节点返回的结果进行反转。
Leaf
Leaf没有子节点,是树的最底层。Leaf可以大致地分成两种, Conditional和Action,Conditional专门用于判断特定的情况,Action就是执行特定的行为,但Action本身也可以返回结果,影响树的执行流程。
Leaf可以设计成具有多个参数,比如一个Walk的Action,可以有Speed和TargetPos的变量。Leaf还可以把计算出来的一些值保存成变量给其它Leaf使用,行为树里面有一个公共变量池,可以被每个节点引用。
行为树的简单例子
如上图,为一个NPC想进入一个房间的行为树例子。留意中间的Selector,它表示有三种途径可以把门弄开。先尝试直接打开,如果不行,就尝试解锁并打开,如果还不行,就直接撞开门。
Behavior Designer
Behavior Designer是Unity最优秀的行为树组件之一。《天天飞车》的《漂移生涯》就是使用了该组件,价格也非常便宜。原本该组件是仅为Unity 4.0以上版本开发的,但我们对它做了一些修改,以让它可以用在Unity 3.5上。
编辑器介绍
在编辑器的Tasks面签,为节点库。开发者通过继承SDK的Conditional和Action基类,可以方便地开发节点。
Variables页签为公共变量,这些变量可以被每个节点进行读写操作,是它们信息传递的重要方式。
Inspector页签显示所选中节点的属性,上面可以设置节点的变量。变量可以是常数,也可以是引用Variables中的变量。
中间就是行为树布局的地方,当游戏运行时,选中Hierachy里的行为树实例,可以看到实时的执行状况。通过右键某个节点,可以设置断点,当行为树执行到该节点时,Unity会自动暂停。
写真赛车AI
《漂移生涯》里面的AI是整个玩法的重要组成部分,AI的行为应该接近于真人操作,在不作弊的情况下,与玩家进行激烈的竞技。AI的行为规则可以归纳为
1.能沿着赛道行前,不漂移的情况下自动过弯,不撞护栏。
2.在比较急的弯道或者高速过弯时,能漂移过弯
3.智能地使用氮气量,能触发完美氮气(二段氮气)
4.能预见性地躲避路上的障碍和其它车辆
5.能预见性地拾取路上的氮气瓶,并且不会因此而发生任何形式的碰撞
6.能预见性地尾随其它车辆,在超车时以便进行“惊险超车”
7.能执行一些通用的小动作,比如获取配置,播放车上的特效和动画,进行刹车,自我销毁等
假如把所有规则都混合在一起,就会非常混乱。想象AI车想拾取路上一个氮气,它又得躲避路上的障碍和车辆,还得不撞护栏自然过弯,情况就很复杂。所以在开发AI前有一个很重要的工作,就是梳理AI规则的优先级。比如上面的规则里面,有的是追利,有的是避害。我们是希望AI以避害为优先,比如预测到即将发生碰撞时,就会放弃拾取氮气。
自动驾驶
让AI车智能地沿着路面的子道行前,自动转弯,随时切道,这样的自动驾驶,是整个AI非常公共通用的功能。在底层车辆的控制接口,仅提供了一个Steer函数,可以传递方向盘的的方向和力度。
问题是影响方向盘方向和力度的因素是很多的,比如当AI车一边转弯,一边切子道,还得防止撞上护栏,最终计算得到的值应该综合上述所有因素,得到一个折中的结果。
这里采用的方法是,把方向盘的力度定为-1~1,分别表示向左最大力与向右最大力。上述每个因素都用一个函数来计算出它需要的力度,返回一个-1~1的值,然后把这些函数的返回的值进行求和。利用这种方法,逻辑变得条理清晰,最终得到的自动驾驶过程非常像真人在操作。
具体到每个因素的计算,沿子道方向的计算方法为,求子道的方向和车头方向的角度,根据是左偏还是右偏,以及角度大小,就得求得方向盘力度。
对于切子道,计算目标子道离赛道中心的偏离量与赛车当前离赛道中心的偏离量之间的差距,就能求得该差距需要的方向盘力度。
对于防止撞上护栏的计算,当检测到靠近护栏,并且行前方向是正在逼近护栏,就全力反方向转方向盘即可。
路面状况检测
检测路面的状况,引导车辆拾取路上的氮气瓶以及躲避障碍,是该AI非常通用的功能。在经典的开发方法里,是当检测到在AI车本地三维空间里,前方有碰撞盒时,进行随机左右躲避的。这种老方法的缺点时,是在即将发生碰撞时才开始进行闪避,而且闪避时没考虑所闪避的方向是否会遇上其它碰撞体。
在开发这个功能时,我把氮气,障碍物,其它车辆的检测都抽象成继承一样的基类,然后各自派生出子类来做部分差异功能的开发。基类做的事情是,检测前面一定范围内的碰撞体,根据其标记的类型,过滤出上述其中一个碰撞体类型。为了方便决策,把整条路分成多个子道,对有指定碰撞体的子道进行标记。对于避害的,子道初始值为Free,有碰撞体的标记为Occupied。对于追利的,子道初始值为Occupied,有碰撞体的标记为Free。最后从车辆当前所在道,向两边同时进行遍历,并且根据当前车头偏离子道的角度,找出最容易进行切道的Free子道。
以上的两个通用功能,通过在行为树里面布局成不同的优先级,设置不同的参数,就可以有很强的复用性。
相对运动碰撞预判
在检测与其它车辆的是否即将发生碰撞时,要注意两者都是处于运动状态的。假如障碍车的行进速度跟自己一样,即使它就在跟前,也不应该认定它是障碍,因为它是相对静止的。
所以,在检测与其它车辆的碰撞时,要计算它与自己的相对速度。为了计算方便,这里还把障碍车转换到自己车辆的本地坐标系下,这样就可以很方便地对左右前后的方向情况进行判断。通过障碍车相对自己的速度,以及相对位置,两者的碰撞盒大小等信息,可以计算出是否即将发生碰撞。
在我们的游戏里面,你经常可以看到AI车互相肩并肩,或者尾随着前进,因为它们的先进速度几乎一样,没有把对方认定为会即将碰撞的障碍。
性能优化
在实际的应用时,不建议所有AI每帧都对行为树进行Tick。Behavior Designer应该设置成手动Tick,根据AI离摄像机的距离来控制Tick的频率。比如离摄像机距离为10米内的,每帧都Tick,500米外的一秒Tick两次,中间距离的Tick频率则进行渐变。
在使用这种方法时还要注意一个细节,比如上图,可以看到CPU有一些以固定频率出现的小峰值。这是因为此时有大量的AI在上述500米外,它们在同一时刻进行了更新。一种很简单的方法是,在计算下一次Tick时间时,叠加一个很小的随机量,这样就能把多个AI分摊到各帧里面,保证同一帧被Tick到的AI数目尽可能少。
我们的游戏里面,不仅AI逻辑会根据距离来改变更新频率,赛车位置方向等其它信息也会动态改变更新频率。
另外,我们使用的Behavior Designer 1.3.5版本里面,有发现SDK内部有静态变量,会导致游戏在退出单局后,单局内资源还处于内存中。我们修改了其代码,单局退出时清空静态变量以保证资源释放。