【译】“分而治之”,一种AI和动画系统的架构

【译】“分而治之”,一种AI和动画系统的架构

是侑虎科技第384篇文章,感谢作者陈杰供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群465082844)

作者联系方式:bitcowboy@gmail.com,同时,作者也是U Sparkle活动参与者哦,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!


译者注:随着国内游戏研发水平的不断提高,对画面品质的不断提升,同时大量手游使用Unity和Unreal 4等成熟的工具开发,动作状态机已经不是什么陌生的概念了。我们在项目开发时也大量使用了动作状态机。但是随着游戏规模变大,我们也踩了很多动作状态机的坑。

前段时间在《Game AI Pro 2》这本书上看到这篇(其实和游戏AI没啥关系的)文章,深受启发。在把这篇文章推给同事们看的过程中,还是不可避免的会遇到语言障碍(不是看不懂,而是大多数国人阅读英文的速度会比阅读中文慢,而大家又实在是很忙),所以就突发奇想决定把它翻译成中文。由于并不是专业翻译,也只能平时抽空做这件事,所以前前后后花了好多天时间,翻译的过程也经常被打断,导致可能文章中会有一些对术语前后翻译不一致的情况。在不影响理解的情况下,还请大家海涵,欢迎大家留言讨论。


序言

为了在如今的游戏中创建出令人信服的角色,我们有两个前提:

  • 首先角色需要能做出正确的决定——AI;
  • 其次他们在将决定付诸实施的过程中还要有好的表现——动画(Animation)。

在当今游戏非常重视视觉表现的情况下,可以说一个AI系统的成败也建立在是否有一个高质量的动画系统基础上。如果Animation系统不能表现出很好的视觉效果,那么AI系统是否做出了聪明的决定都显得不那么重要了。

虽然我们已经在之前的游戏中提升了Animation系统的还原度,但是却付出了巨大的工作量。这些工作量不仅包含了大量的动画数据,也包含大量用来关联动画的结构数据,以及相应的控制和驱动代码。如今我们面临最大的挑战简单来说就一个——复杂度。我们如何能有效的管理、利用和维护这些新产生的内容?

我们觉得传统的技术在处理上一代游戏数据量的时候就已经达到极限了。这一代主机和上一代主机相比,不管是内存容量还是玩家的期望值都经历了指数级的增长。我们没有理由怀疑游戏容量上也会有类似的增长,因为,我们需要花点时间来重新考量和调整我们的工作流程及软件架构来更好得消化内容和复杂度上的增长。

基于我们在开发上一代和这一代游戏中获取的经验,本文试图提出一种软件架构来管理现代动画系统的复杂性。


一、动画图(Animation Graphs)

在我们讨论更高层的架构前,我们先来快速回顾一下现代的Animation系统。动画图(Animation Graphs,animgraphs)在业内被广泛用于表示在游戏内一组Animation是如何被关联起来完成一个行为的。

简单来说,Animgraph就是一张有向无环图。图中的叶子节点代表了动画源文件,而分支节点则是对动画的操作(例如混合,Blending)。因为主要表述了一组动画源文件如何混合在一起,所以我们通常把这类动画图又叫做混合树(Blend Tree)。Blend Tree中的操作通常通过控制参数(Control Parameter)来驱动。例如,将两个Animation混合需要一个“混合权重”控制参数来指定每个Animation对最终混合结果的贡献度。这些控制参数是我们用来控制(或驱动)Blend Tree的主要手段,此外还有动画事件(Animation Events)。

Animation Events是打在动画源文件上的时间附加信息。它提供了关于Animation自己和其它系统关联的上下文信息。举例来说,在一个走路的动画中,我们可能想标识出左脚或右脚落地的时刻,好让游戏触发相应的脚步声。Animation Events从各个源文件中采样然后沿着图向上传递直到根节点。在此过程中,这些Animation Events也能被分支节点用作判断条件,尤其是在状态机的状态转换中(Transitions)。一个用于表述角色向前移动的简单Blend Tree如图12.1所示。图中,我们能通过“Direction”和“Speed”两个控制参数来控制角色的方向和速度。
请输入图片描述

除了混合(Blending)之外,我们也能通过分支节点在两个Animation之间做选择(Select)。如图12.1中,我们可以用一个“Select”节点来替换对速度的混合,从而可以在走和跑之间选择特定的Blend Tree。

虽然一个Blend Tree足以用来执行一个行为所需的所有操作,但只用一个Blend Tree却难以涵盖一个角色的所有可能行为。因此,我们希望能够对每个行为单独构建一个Blend Tree,然后再通过某种机制在它们之间切换。这些行为之间通常都有预先定义好的序列来约束彼此之间哪些可以被连接在一起,依照传统,这种切换机制采用了状态机的形式。

在这些状态机中,每个状态就是一个Blend Tree,状态变迁(Transition)会在两个Blend Tree之间进行Blend。每个状态变迁都有一组条件(Condition),一旦条件满足,就会发生Transition。这些条件同时也需要检查一些与Animation相关的判断(Criteria),例如控制参数的值、Animation Events以及一些基于时间的判断,如是否Animation已经播放完了之类的。此外,状态本身还包含用来控制和驱动Blend Tree的逻辑(为Blend Tree设置相应的控制参数等)。

有了状态机,我们就能够将所有单独的动作组合在一个系统中,通过把相应动作串起来,从而能让角色做出各种复杂的行为。下面让我们再来看看图12.1中的那个例子,由于他只包含了向前移动,我们首先要扩展朝向来包含全部朝向。我们还缺少角色停下来不动的时候的Animation,我们需要添加一个Blend Tree。于是我们需要一个状态机用来在这个两个Blend Tree之间进行切换。紧接着,我们发现从移动到停止的切换过程看起来很生硬,我们想在移动和停止之间增加两个过渡状态来引入自然的过渡动作。然后我们发现,当我们要停下来的时候,我们哪只脚先落地关系到我们如何播放过渡动画,于是我们又加了两个状态来区分左右脚,还要通过在走路动画中打的Animation Events来正确迁移到相应的状态。最终,我们得到了一个如图12.2所示的状态机。
请输入图片描述

我们已经能够看到,即使在如此基础的设置中也已经存在大量的复杂度了。考虑到每个Blend Tree事实上还有各自不同的控制参数以及相应的控制代码,每个Transition的触发逻辑和各个状态的设置和驱动。考虑到现在的游戏角色通常都有几十个行为,每个包含数个Blend Tree,以及每个行为对应的状态逻辑。我们已经面临复杂性爆炸,并且还要在废墟中继续前进。


二、复杂性爆炸和扩展性难题

说到扩展性,不得不说状态机。传统上,很多开发者可能会复用同一个状态机来同时驱动Animation和游戏逻辑(Gameplay)状态变化。不仅包括玩家角色的状态机,也包括在状态上采用类似行为树(Behavior Tree)或其它决策结构的AI状态机。我们在这里统一使用状态机来指代各种AI状态变更机制(行为树、规划器等等)。简而言之,后文中我们用游戏逻辑状态机(Gameplay State Machine)来指代任何上层AI或玩家行为系统。

复用上层的Gameplay状态机来驱动Animation有很多问题,其中最主要的问题就是代码和数据的耦合。由于Blend Graph并不写死在代码里,可以认为它们是在运行时加载的资源数据。但是负责设置必要的控制参数来驱动Blend Graph的又是代码。因此,代码必须要明确知道有哪些控制参数以及分别是干嘛的。很重要的一点是,控制参数通常是为Animation设计的(例如归一化了的Blend值[0-1]),所以Gameplay代码需要去把相应的参数转换成Animation系统能用的形式(例如,把图12.1中的方向值由角度值转换成0-1之间的Blend权重)。在我们的代码中,这种明确知道如何转换特定的控制参数就已经在代码和Blend Graph之间形成了耦合。只要我们修改了Blend Tree,就要配套修改代码。这种代码和数据的耦合看起来无法避免,但是我们还有很多方法可以尝试让这种耦合尽可能远离Gameplay代码,从而减小风险,同时加快迭代开发的速度。

复用Gameplay状态机的另一个大问题是状态间不一致的生命周期,因为并不存在Gameplay状态和Animation状态一一对应的关系。举例来说,一个简单的移动行为,在Gameplay的角度来看,一个状态就足够表示角色在移动了,但是从Animation的角度来看,我们需要一系列状态和Transition来实现移动。这常常意味着最后我们在Gameplay状态机里内嵌了一个只有Animation的状态机。随着开发过程的不断推进,这两个状态机之间的界限慢慢变得模糊了。事实上,这是对代码和数据耦合的主要担忧。因为一旦我们要对Blend Tree做些什么大改动,代码就要跟着改,不幸的是,由于两个系统如此交织在一起,这样就会影响到甚至完全让Gameplay停摆。更糟的是,因为Animation和Gameplay两个系统如此紧密连接,自然会有程序员直接用Blend Tree的信息或者假设Blend Tree有特定的结构来做Gameplay的判断。那么,一旦Animation修改了,就必然导致需要重写大量的Gameplay代码。

图12.2的例子其实有一些误导:Idle状态是一个单独的Gameplay状态。如果我们要为角色创建一个额外拥有跳跃和攀爬的Gameplay状态机,我们可能会得到一个如图12.3所示的结果。
请输入图片描述

虽然图12.3看起来已经非常复杂,但事情还没那么简单。现在,Gameplay状态之间的切换还要承担驱动Animation切换的工作。举个例子,当我们在Idle状态和Jump状态之间切换时,我们要设法让Jump状态知道我们是从哪个Animation切换过来的,如此我们才能正确选择新的Animation状态。修改或者新增新的Animation Transiton就意味着我们需要修改Gameplay的状态切换及其代码和动画逻辑。随着我们的系统日益复杂,维护和除错变成了一场噩梦。在开发的后期,新增一个状态的成本和风险变得高到令人无法接受。要想继续发展并避免这种窘境的最好办法就是设法解开两个系统之间的耦合,这也是“分而治之”(Separation Of Concerns、SoC)这个架构的由来。


三、分而治之(SoC)

SoC的原则是,多个相互交互的系统,应该各司其职,而不应该有相互重叠的职责[Greer 08]。显然,图12.3里的两个职责混乱的状态机并不符合这个原则。首先,我们试图将Animation的逻辑从Gameplay逻辑里拆出来。这个相对容易,并且很多开发者已经用专门的Animation系统来支持Animation状态机(例如Morpheme、Mecanim、EmotionFX)。可惜,Animation状态机的概念并没有想象中的那么普及,即使在Unity和Unreal引擎中也是到了他们的第四版才开始支持。正如上一节介绍的,Animation状态机是在Animation系统一层的用来定义和转换Animation状态的一个简单状态机。此后,我们会用“Animgraph”来表示Blend Tree和状态机组合而成的一整张图。

Animation状态机也可以是分级的,它的Blend Tree的叶子节点也可以是一个额外的子状态机。这让我们能容易地在一个Animation的基础上实现分层(Layering)Animation。不过并不是所有的Animation系统都支持这个特性。比如,Natural Motion的Morpheme中间件就是完全基于这个概念搭建起来的。而Unity的Mecanim对一张Animgraph则只支持一个单一的状态机,但在此之上却允许有多个Graphs同时运行充当不同的层(Layering)。

如果我们把图12.3中的Animation状态机逻辑全部提取出来,我们就会得到一个如图12.4所示的状态机。正如你所看到的,分离出来的Animation状态机依然很复杂,但是这种复杂性可以进一步通过使用只包含单一行为(如“Jump”或“Ladder”)的分级状态机来简化。Gamplay状态机现在可以只关系游戏逻辑的状态变迁而完全不用去管Animation要怎么切换。另外,从某种程度上来说,我们现在有可能独立修改各个状态机了。
请输入图片描述

这里还有个问题。虽然初步的分离对我们解耦系统有很大的帮助,但是在Gameplay状态机和Animation状态机之间耦合依旧。我们还是需要明确理解控制参数并驱动状态机,即明确理解Animgraph的拓扑结构以便决定何时开始切换到何状态,何时结束。这也意味着我们依旧需要很多代码来驱动和查询Animation系统,很不幸,这些代码看起来也和状态机差不多。不知道你是否发现一个令人惊讶的事,我们才把Animation状态机从Gameplay状态机里拆出来,事实上却得到了3个纠缠不清的状态机:用来控制Player的Gameplay状态机,用来描述动画和衔接的Animation状态机,和用来充当中间层的Animation驱动状态机。事实上这个一直存在的隐含状态机正凸显了构建一个大一统系统的危险。

说回Animation驱动状态机,它的职责之一是检视Animation状态机并为Gameplay系统提供必要的信息。同时,它还需要负责将Gameplay中的控制参数转换成Animation系统能理解的形式,并在必要的时候触发Animation状态变迁。在很多情况下,这些驱动代码也负责对Animation做必要的后处理,例如,对旋转和位移的后期修正。正因如此,我们依然有很多不同的职责参杂在一个系统中,我还没能真正意义上提高角色的可维护性和扩展性。为了更进一步,我们需要将所有和Animation相关的代码都从Gameplay系统中抽离出来,以消除在Animation数据和Gameplay代码之间残存的耦合。


四、分离Gameplay和Animation

我们可以通过两件事情来抽离Animation驱动代码,第一件相对简单并对简化Gameplay代码大有益处,第二件则明显更耗时费力。如果你已经在项目的开发末期,可能前者会更有帮助。

我们提到Animation驱动代码的一个关键职责是将上层Gameplay逻辑的需求,像是“在左转53°的同时以3.5米/秒的速度移动”,转换成Animation层的控制参数,也许看起来像是“方向=0.3,速度=0.24”(用于合成最终视觉效果的blend权重)。为了完成转换,显然Gameplay代码需要知道有哪些可用的Animation,有哪些Blend组合存在,哪个值控制哪个Blend之类的知识。几乎等于驱动代码要做类似于角度到Blend权重之类的转换就要完全了解Blend Tree。这就意味着假如有个动画师修改了Blend Tree,Gameplay代码就可能因为失效而需要修改。这导致任何对Animgraph的修改都需要程序员和动画资源配合,并可能花费相当长的周期才能把代码和数据变动集成到版本中交给产品团队。

有一个简单的方法来规避这个问题,将所有的转换逻辑移到Animgraph里去(直接将上层Gameplay用的值传递给Animgraph)。基于你采用的Animation系统,这个可能做得到,也可能做不到。例如,在Unreal 4中可以通过蓝图(Blueprints)轻易实现,而在Unity中貌似没有办法在图里对控制参数做任何数学运算。把转换逻辑移到图里有两个好处:

  • 首先,Gameplay代码无需知道任何关于图和Blend的细节,它只需要知道它需要把它所理解的“方向”和“速度”值发给Animation系统。通过把和代码的耦合转移到Animgraph里,现在动画师可以对Animgraph大改而不会导致Gameplay代码需要修改。实际上只要输入的参数不变,即便他们整个换了张图也没关系。在图12.1的基础上,我们把所有的转换逻辑都放到图里,就得到图12.5。
    请输入图片描述

  • 此外,我们还能将除了转换逻辑之外的更多控制参数逻辑移到图里(例如,抑制输入值来来获得向新值得平滑过度而不是跳变)。

仍然值得指出的是,Gameplay的代码可能依旧应该了解Animation的能力极限(例如,转向的大约限制、合理的移动速度,不过不需要很精确)。实际上,通常是由Gameplay来确定这些约束的。试想我们采用图12.5的设置,玩法上想要角色做一个冲刺行为,游戏逻辑团队只需要给出一个更快的移动速度参数并告诉动画团队。动画团队现在可以创建一个新的动画放进去,而无须游戏逻辑团队的干预。从技术角度来看,把转换逻辑从代码转移到图中消除了一层耦合,使得我们离最终理想的SoC架构更近一步。

实现SoC要做的第二件事是,将所有Animation驱动状态机的代码从Gameplay状态机中移到一个处于Animation系统和Gameplay代码之间的新的中间层。在经典的AI代理人架构中[Russel 03],作者将一个代理人分成三个层次:感知层、决策层和行动层。这本身就是一种SoC的设计,我们可以直接套用过来。如果我们把Gameplay状态机看作决策层,Animation系统看作最终的执行器,那么我们需要一个行动层来将来自决策层的指令传输给执行器。这个新的层由Animation控制器和Animation行为组成。对于任何动画需求,Gameplay系统都会直接和这个新的层来交互。


五、动画行为(Animation Behaviors)

动画行为(Animation Behavior)是一个通过执行一系列操作从视觉上来实现角色行为的程序。因此,动画行为单纯从视觉上来考虑角色行为,并不负责任何游戏逻辑的状态变化。但并不是说它们对游戏逻辑完全没影响,游戏逻辑和动画行为之间的信息流通是双向的,会间接导致游戏逻辑状态的改变,但这些改变不是由动画行为来执行的。实际上,我们推荐(在你的引擎架构中)将动画行为放在游戏逻辑之下,动画行为应该根本访问不到游戏逻辑。

在讲解动画行为的过程中,我们发现从动画系统开始讲起再回到游戏逻辑会更容易理解。那就让我们先来看看如图12.6所示的Animgraph的例子。我们有一个包含角色所有能做的全身动画的动画状态机。在这个状态机中,我们有个一状态叫“移动(Locomotion)”,它又包含了一个包含所有移动状态的状态机。每个状态又包含了相应的Blend Tree或子状态机。
请输入图片描述

接下来,让我们来构建一个“移动(Move)”动画行为。要让这个行为正常工作,它需要了解Animgraph(尤其是“移动”状态机),内包含的所有状态,以及每个状态的内容。一旦我们给出图上所有的拓扑信息,动画行为就要来驱动状态机,也就表明它需要了解所有的控制参数及上下文。有了这些信息,动画行为就准备好干活了。干活分三个阶段:

  • “开始(Start)”
  • “执行(Execute)”
  • “停止(Stop)”

“开始”阶段的主要职责是确保Animgraph处于一个执行阶段能够继续处理的状态。举例来说,当从站立开始移动,我们需要触发“从站立到移动(Idle to move)”的过渡(Transition)并等它完成。只有当过度完成,我们处于“移动”状态,我们才能转到“执行”阶段。如果是要做路径跟踪,那么我们还需要在这个阶段进行寻路和路径后处理。

“执行”阶段负责主要的工作,驱动Animgraph产生所需的视觉结果。在移动的情况下,我们需要执行路径跟踪模拟,并为Animgraph设置正确的方向和速度控制参数。一旦我们完成了任务,就开始转到“停止”阶段。

“停止”阶段负责完成所有的清理工作,并将Animgraph切换到可以继续做其它行为的中立状态。在我们的移动例子中,我们需要在这阶段中释放路径并触发“移动到停止(Move to idle)”的过度(Transition),然后结束这个行为。

需要注意的是,图12.6中的例子里,“站立”和“移动”之间的过渡其实是存在于“全身动作”状态机里的。这意味着,“站立”和“移动”都需要了解“全身动作”状态机。其实,所有包含在“全身动作”状态机里的状态都要了解这个状态机。这就引出了Animgraph试图的概念。Animgraph视图是一个能够识别图的一个特定局部并包含驱动这个局部所需的工具函数(Utility Functions)的对象。根据这个描述,动画行为本身其实就是Animgraph视图,只是还包含了执行过程(Execution Flow)。我们最好把视图理解成工具库(Utility Libraries),而把动画行为看成一个程序。多个动画行为可以共享使用同一个视图,以便提成代码的复用度,减小因Animgraph变更带来的代码修改。在我们的例子中,我们会有一个了解“全身动画状态机”拓扑结构的“全身动画视图”,并提供一些方便触发状态过渡的函数,例如,设置全身状态(站立)。

要执行给定的任务,动画行为还需要一些指示和目标。目标来自于动画指令,动画指令由游戏逻辑发出,并包含所有执行给定行为所需数据。例如,如果游戏逻辑想要移动一个角色到特定的位置,它就会发出一个带有目标点的“移动指令”,要求的速度,结束时的朝向等等。每个动画指令都带有一个类型,并对应产生一个动画行为(例如,一个“移动指令”对应产生一个“移动行为”)。动画指令一旦发出之后就不管了,游戏逻辑并不能控制动画行为的生命周期。取消或更新动画行为的唯一方式是再发一条新的指令,这点我们在下一节详细介绍。

除了动画指令外,我们还有动画行为句柄的概念,它在发出动画指令时得到。这些句柄将成为动画行为和游戏逻辑相互沟通的机制。首先,通过句柄,游戏逻辑可以检查一个已经发出的动画指令的执行状态(例如,这个指令是完成了还是失败了,及其原因)。动画句柄包含一个指向动画行为的指针,通过它来执行对动画行为状态的必要查询。在某些情况下,例如对于一个玩家角色,最好每帧都能对一个行为进行更新(例如,动画行为每帧都能根据控制摇杆的数据来设置动画控制参数)。

在图12.7中,我们简单画出了一条游戏逻辑和“移动”动画行为之间相互交互的时间轴。需要注意的是,游戏逻辑和动画行为之间是如何通过动画句柄来完成所有沟通的。
请输入图片描述

在三个主要阶段外,动画行为还提供了一个动画“后处理”阶段。这个阶段主要用于执行类似于轨迹变换(Trajectory Warping)之类的后处理操作,但也可用于物理和动画计算完成后对姿态的再修改(例如,反向动力学、LK)。另外插一句,LK和物理/动画交互理想情况下应该作为动画更新的一部分,但是并不是所有的动画系统都支持这么做。

既然我们需要很多个动画行为才能包含角色所有的行动(Action),我就需要某种机制来调度和运行他们。而这就是动画控制器的由来。


六、动画控制器(Animation Controller)

动画控制器的主要角色就是充当动画行为的调度器。它是上层Gameplay系统通过Animation命令来向底层Animation系统发出请求的主要接口。它负责创建和执行动画行为。同时,动画控制器还提供动画行为分轨(多队列)的功能,用来实现多层动画。例如,类似“注视”、“装子弹”或“挥手”等行为可以和其它的全身动作(如“站立”、“走路”)一起做,因此,我们可以在控制器中将这些动画实现成一个叠加在其它全身动画上的分层动画。在以前的游戏中,我们发现一般只需要两个动画层就足够了(特别是人形角色):一个用于全身行为,另一个用于附加行为[Anguelov 13, Vehkala 13]。对不同的分轨,我们还有不同的调度规则。在任何时间我们只允许一个全身行为处于激活状态,而附加行为可以同时有多个。

对于全身动画行为,我们的队列中有两个插槽。一旦有全身动画指令被发出,我们就会将其放入主插槽。如果我们又收到另一个全身动画指令,我们会创建一个新的动画行为,并尝试将其和之前的行为合并。动画行为和一个简单合并机制就是更新动画行为的指令。例如,如果我们发出了一个移动到A点的指令,我们会创建一个以A点位目标的移动动画行为。如果之后我们觉得B点其实是更好的终点,我们就会再发一个以B点为目标的移动指令。这会产生一个新的动画行为,并通过更新原有动画行为的指令进行合并。合并后,第二个动画行为被废弃。一旦合并过程完成,动画行为会检测到更新后的指令,并作相应的处理。如果新的动画指令产生出与之前不同类型的全身动画行为,那么新的行为会被放进第二个插槽并执行。当然,我们还要通知第一个行为该终止了。终止一个行为强制将使其进入“停止”状态并完成。一旦一个动画行为完成了,它就会被从队列里移除并不再被执行。这意味着我们本质上有能力在两个全身动画之间交叉混合(Cross-Fade),以便在转换的过程中实现更好的视觉质量。

合并机制要求所有的动画行为都支持指令被更新。乍一看这种更新动画行为的方式很奇怪,但对游戏逻辑代码而言大有益处。最主要的是,游戏逻辑不必再关心动画什么时候结束,也不必关心不同动画状态之间如何迁移,这些都由动画控制器和动画行为处理了。当需要在不同系统间转移对动画的控制权时,例如触发剧情动画时需要由协议动画系统来控制角色动画,由游戏逻辑来精细化管理动画状态变迁本身也是极其有问题的。当控制权转移时,角色有可能处在任意状态。剧情动画系统需要用合适的方法去恢复状态,这在以前,除非把几个无关系统耦合起来(例如让协议动画系统了解AI在干什么),否则是非常难以实现的。用我们的方法,就可以在无需将无关系统耦合起来的前提下,随意转移动画的控制权。例如,可以为协议动画编写动画行为,当协议动画开始时,它就发出终止现有指令的指令,并在指令之间做出合适的衔接。实际上,协议动画系统甚至都可以直接复用AI使用的移动指令,因为它们是系统无关的。试想,我们需要一个NPC在剧情动画中爬梯子。我们并不需要重新制作一个完整的长动画,也不需要去编写AI脚本让NPC爬梯子,我只需要让协议动画系统直接发出动画指令而不用担心AI和NPC状态。

这种方法对动画方面也有额外的好处。万一由于某些原因,游戏逻辑不停地发出动画指令,也就是说行为振荡,我们的全身动画队列机制只需要将新的行为简单的覆盖/合并到队列里已有的行为上。这极大得缓解了传统上会出现的视觉抖动问题。坏处是,由于看起来没啥问题,也使得QA更难去发现那一类Bug。所以,我们建议你实现某种动画指令滥发检测机制。

对于附加动画行为,我们允许队列里有任意多个行为并存。这需要由游戏逻辑来保证这些这些组合是合理的。对附加动画我们也使用和全身动画一样的合并规则。

关于调度动画行为还有最后一件事需要讨论:动画行为的生命周期和发布它的游戏逻辑状态是不同步的。一旦一个动画行为完成了,它就会被从队列里移除,但却不能立刻删掉,因为以不同频率执行的游戏逻辑仍可能通过句柄继续访问它。反之亦然,游戏逻辑可能不等动画行为完成就先结束了。因此,我们决定按共享所有权(Shared Ownership)的概念来管理动画行为的生命周期。一个动画行为,只要还有句柄指向它,或它还在动画控制器的某个队列中,它就继续存在(内存中)。这可以通过STL的Shared_Ptr智能指针来轻松实现。最终包含所有系统层级的整个架构,如图12.8所示。关于“控制器/行为”(Controller/Behavior)架构的更多细节,大家可以参考[Anguelov 13]。
请输入图片描述


七、SoC架构的好处

直到现在,我们都是围绕着如何解决已有的问题来讨论SoC。然而,必须要指出,转向SoC架构还有一些额外的好处。虽然不是显而易见,但是我们还是有必要在这里提一提其中的一部分。

1、功能测试

第一个显著的好处是,现在我们可以进行独立的功能测试了。例如,我们想测试AI系统,那么我们可以创建一个假的Animation控制器,它除了接收命令并根据需要返回成功或失败外,并不真正的执行任何Animation的代码。这样能极大的简化调试AI问题工作,而完全不必考虑Animation代码是否有问题。而在先前那种相互交织的系统中,我们很难真正定位问题的根源。而这也同样适用于Animation的测试。曾经,为了测试我们的Animation层,我们还不得不另外搭了一套脚本系统。这套脚本系统完全模仿AI发出指令,但能让我们在一个隔离的环境中测试。这在我们上一个项目的多个Gameplay代购重构过程中,对维护和验证Animation功能都起到了极大的作用。

2、系统重构

这个方法的另一个巨大好处是,当我们想对Animation做一些大的改动时,可以变得更安全更简单。这个架构让我们能够逐个替换角色的行为,而不用担心会搞坏Gameplay代码。此外,这个方法允许我们进行非破坏性原型开发。当在构建一个行为的新版本的时候,我们只需在现有基础上创建一个新的Behavior,而由Controller在运行时动态切换。这个方法优雅之处在于,我们可以在运行时动态替换Behavior而完全不需要Gameplay代码知晓。如果我们结合前面讲的功能测试,就能够做到构建一个新版行为然后和旧的一起对比,而无须修改旧的行为也无需修改Gameplay代码。这使得我们能够在保证最基础功能的前提下快速创建原型,并今早和Gameplay整合,在不影响版本的前提下实验各种效果,逐渐完善最终版本。

3、细节分级

由于能在不影响Gameplay的前提下动态切换动画行为,这使我们能够利用这点来构建一套动态动画细节分级系统(LOD)。在屏幕外角色不能直接销毁,AI还要继续运行的情况下,我们需要某些机制来减小或消除Animation的运行开销。如果NPC的移动是基于Animation的,那么在Animation和Gameplay没有分清楚的情况下,要实现这个功能就会非常复杂。

通过我们的方法,我们可以创建一些开销很小的替换行为,用以在运行时根据角色的LOD等级动态替换[Anguelov 13]。当我们的NPC处于最高LOD级别时,我们会运行默认的Animation行为。当角色逐渐远离,LOD等级下降,我们可以用轻量级的行为替换掉一些消耗很大的多层叠加动画来减小开销。当角色完全移出屏幕时,我们可以将全部Animation行为替换成空行为,只维持切换回高级别LOD所需要的Animation状态。

拿移动举个例子。在最高LOD等级时,我们运行完整的包含脚底LK的移动动画。在中等LOD时,我将脚底LK替换成一个空行为,移动动画保持不变。在最低级LOD时,我们只定时更新位置并估算速度而不执行动画。一旦角色重新进入屏幕,我们即可将标准的移动动画行为无缝切换回来。我们建立为不同的LOD等级创建单独的动画行为,这样使得你能够通过对不同行为的组合为不同角色创建出各自的LOD集合。例如,对于体型巨大的角色,即使在中等LOD级别的时候,你也想保留脚底LK,因为他们的滑步会比小体型的角色看起来明显得多。


八、总结

在这篇文章中,我们提出了一种解耦Gameplay系统和Animation系统的方法。我们讨论了这种方法对生产力和可维护性的潜在改进,并给出了如何向类似方法过渡的建议。


文末,再次感谢陈杰的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)。
也欢迎大家来积极参与U Sparkle开发者计划,简称"US",代表你和我,代表UWA和开发者在一起!