欢迎光临散文网 会员登陆 & 注册

模块化的AI(上) | Game AI Pro

2020-05-25 16:42 作者:有木乘舟  | 我要投稿

Modular AI 

Kevin Dill and Christopher Dragert

8.1 Introduction

  重复在人工智能中无处不在。相同的模式、相同的代码片段、相同的数据块,相同的子决策在一个又一个决策中反复使用。一般情况下,工程师看到有重复的代码,都会试图将其封装起来:将其放入程序中,放入类中,或着构建一些抽象,使得重复的模式只有单个实例。这样就可以重用这些封装,而不需要为每个新的用例再重写一次。

  封装在软件工程中是很常见的方法:在程序、类、设计模式、C++模板和宏中,以及在数据驱动设计。

  减少重复有很多好处。它减小了可执行文件的大小,它减少了引入bug的可能性,并增加了测试代码的方式,它避免了你只能在一个地方修复bug或改进功能。

  封装和复用能节省大量的开发时间,关注于新功能的实现,而不用重写已经存在的代码。它允许您构建健壮的、功能丰富的抽象,这些抽象可以执行复杂的操作,这些操作通常需要花费大量的时间来实现,且较少使用。

  然而,除了这些附加的优势之外,它还提供了一些基本的东西。它允许你将底层细节封装,用易于理解的方式封装成可在项目中被重复利用的模块。它允许你以最接近人类的思维方式去实现程序,站在设计师的角度而不是机器的角度去思考实现。

  它会改变一些东西,比如:

  变成:     d = Distance(a, b);

  然而,人工智能面临的挑战是,虽然决策的某些方面往往与其他决策相似,但也总是有一些完全不同的方面。比如射击和吃饭,人工智能都需要测量自己跟对象之间的距离,以确定是否射击,或在那里吃午饭,但受评估的物体和距离的长短肯定是不同的(除非你构建的 NPC喜欢在餐厅射击敌人并且把敌人吃掉)。

  因此,虽然距离函数本身是大多数数学库的标准部分,但在基于距离的决策中涉及的代码量要大得多,这就更难封装和重用。

  模块化人工智能本质上就是封装和复用。它能让你站在人类思维的角度将决策逻辑转化为模块化的组件。它是关于创建这些模块化组件的集合,能让你在下一个、下一个的下一个的游戏的AI中反复使用。它能让你在大部分时间里站在人的角度去思考问题,去建立起从个体概念(如距离、视线、移动、用武器攻击)到更大范围的行为(掩护、选择和攻击目标)到整场演出(远程武器战斗)的逐步增长的视角,然后在其他地方重新使用这些片段,并进行适当的定制。

  这种方法将使您能够更快地创建决策逻辑,更容易地更改它,并更广泛地重用它。同时使工作更可靠,产生的错误更少,因为底层代码的大量重复使用,让测试更加鲁棒性,也因为可重用组件的改进,使得根据需要立即向某种情况中添加新的功能成为可能。

  本章将首先讨论模块化人工智能的理论基础,并将它们与软件工程中广泛接受的概念联系起来,然后详细描述游戏人工智能架构(GAIA)。

  GAIA是洛克希德马丁公司Rotary and Mission Systems开发的一种模块化架构,在许多不同的游戏和模拟引擎,包括(但不限于)教育游戏和训练模拟引擎中,它被用于驱动许多非常不同的项目的行为。

  它的根源可以追溯到Blue Fang Games的动物人工智能,Mad Doc Software的动作游戏中的boss人工智能, 以及Rockstar Games的人群(ambient human,围观群众?吃瓜群众?)人工智能上。

8.1.1 Working with this Chapter

  这篇文章会深入探讨一些问题,不同的读者可能会对不同的方向感兴趣。如果你的主要兴趣是模块化人工智能背后的核心思想,以及模块化部件如何协同工作,那么你的重点应该放在8.2、8.5和8.6部分。

  如果您感兴趣的方法是可以马上用在现有的体系结构中,而不必从头开始,那么您应该重点关注实现需要考虑的事项(第8.5.1节和第8.6节)。

  最后,如果你对一个完整的架构感兴趣,这个架构可以在许多项目中,在许多游戏引擎中重用,并且允许你以模块化的方式快速配置你的人工智能,那么整个章节就为你准备好了!

8.2 Theoretical Underpinnings

  模块化人工智能,以及一般的模块化方法,试图提高开发的抽象层次。一个好的模块化解决方案不是关注算法和代码,而是关注人工智能行为以及它们如何组合在一起,从而抽象出实现细节。问题是如何安全、正确地完成这项工作,同时仍然为设计人员和开发人员提供获取预期行为所需的细粒度控制。

  模块化人工智能开发的成功取决于良好软件开发中的相同原则:封装、多态性、松耦合、清晰的操作语义和复杂性管理。这些熟悉的概念在模块化上下文中都获得了新的含义。

  模块本身封装了一个人工智能功能单元。好的模块遵循“金发女郎法则(Goldilocks Rule)”: 不要太大,不要太小,但尺寸要刚刚好。太大的模块往往包含多个功能并禁止重用,那如果一个新的人工智能只需要部分功能怎么办?太小的模块则不足以提高抽象级别。

  我们的目标是将游戏设计师在游戏中为npc设计的基本逻辑行为(同一水平的、相似的那些)收集起来,将其抽象化、模块化。然后,开发问题就变成了选择和集成新NPC所需的行为和能力,而不是从头实现这些能力,这是一个非常合适的抽象级别。

  以这种方式使用模块需要安全地重用模块。为此,必须严格执行模块封装。防止模块之间的意大利面条式交互(spaghetti interactions,互相纠缠分不清)可以确保每个模块可以独立地正确运行。这对于重用是必不可少的——即使模块之间的细微依赖也会很快成为问题。

  封装自然会导致模块接口的创建。与API非常相似,模块接口准确地描述了如何与该模块交互。它显示了它可以接受的输入、它提供的输出,并详细说明了应用于特定AI时为自定义模块行为而公开的参数。它显示了它可以接受的输入、它提供的输出,并详细说明了应用于特定AI时为自定义模块行为而公开的参数。有了显式接口,处理行为之间的依赖关系(即连接正确表达行为时所需的输入和输出)就变成了一个更简单的问题。每个模块都被正确的封装后,添加和删除新模块所导致的结果将变得可预测、可控。

  多态性是这种松散耦合(loose coupling)的结果。想象一下一个模块的功能是逃离敌人,作为一个比特大小的模块,它可以执行所需的检查和测试,以找到合适的逃离目的地,然后发出一个移动的输出信号。接收此输出的模块不再是关键问题,一个人工智能可以使用某种类型的移动模块,而另一个人工智能可以使用另一个。移动模块的确切类型不应该是关注的重点。复杂的因素,比如“我的NPC是骑自行车的吗”或者“她是骑马的吗”等等,都可以由移动模块或者其他子模块来处理。这使得每个模块都能清晰地专注于单一的功能目的,同时确保相似的行为不会在模块之间重复出现。

8.3 GAIA Overview

  GAIA是一个模块化、可扩展、可重用的工具集,用于指定过程决策逻辑(即a i行为)。GAIA强调了游戏设计者在创建决策逻辑方面的作用,同时仍然允许行为的产生是灵活的,并对应用程序中的即时情况做出响应。

  GAIA有以下特点:

  • 可用于指定过程决策逻辑(或“人工智能行为”)的工具库。

  • 专注于提供创作层面的控制权。换言之,GAIA的目标不是创造一种真正的人工智能(它可以自行决定做什么),而是向设计师提供确定决策的工具,来实现预期的体验,同时仍然保持足够的灵活性,以处理各种各样和意料之外的情况。

  • 模块化,这意味着行为通常是通过插入预定义的组件来构建的。经验表明,这种方法大大提高了设计和开发的速度。

  • 可扩展,使向库中添加新组件或更改现有组件的行为变得容易。

  • 可重用,这意味着GAIA是从一开始就考虑重用的。这包括代码和数据的重用,以及当前项目、未来项目甚至不同游戏引擎中的重用。

  GAIA是数据驱动的:行为在XML文件中指定,然后由代码在运行时加载。本章一般将XML称为配置或数据,C++为实现或代码。为简单起见,本章还将使用术语NPC表示GAIA控制实体,PC表示玩家控制实体,角色表示为NPC或PC的实体。

8.3.1 GAIA Control Flow

  GAIA通过决策树(推理树)进行决策,这在许多方面与Damian Isla最初的行为树(BT)愿景相似(Isla 2005)。与在BT中一样,不同的推理机可以使用不同的方法来做决策,这使得体系结构更具有灵活性,这在按性质分层的方法中是不可能实现的(例如,分层有限状态机、远程主动编程、分层任务网络规划器等)。

  每个推理机从它的选项池中选择策略。选项池中包含了一些考虑事项(considerations),推理机可以通过这些注意事项来决定选择哪个选项,以及选择了这个选项后可执行的操作。动作可以是具体的,也就是说它们代表了被控制角色应该实际做的事情(例如移动、射击、说一行对话、害怕地畏缩等),或者是抽象的,也就是说它们包含了更多的决策逻辑。

  最常见的抽象操作是AIAction_Subreasoner,它包含另一个推理机(带有自己的选项、考虑事项和操作)。子推理机行动是GAIA用来创建其层次结构的机制。当选择包含子推理机的选项时,该子推理机将开始计算自己的选项并选择要执行的选项。这一选择可能包含具体行动,也可能反过来包含另一个次级推理机行动。

  选项还可以包含多个操作,这允许它们具有多个具体操作、子推理机或两者的组合,所有操作都并行进行。

8.3.2 GAIA Implementation Concepts

  推理、选择、考虑和行动都是概念抽象的例子。概念抽象是构成模块化人工智能的基本对象类型。每个概念抽象都有一个定义它的接口,以及一组实现该接口的模块化组件(或仅仅是组件)。如上所述,有多种不同类型的推理机,但所有推理机(即,实现推理器概念抽象的所有模块组件)共享同一接口。因此,周围的代码不需要知道它拥有什么类型的组件。例如,推理机不需要知道正在使用什么特定类型的考虑因素(considerations)来评估一个选项,或者这些考虑因素是如何配置的,它只需要知道如何使用考虑因素接口来获得它需要的评估。

  这是模块化人工智能背后的关键思想:识别人工智能的基本部分(概念抽象),为每个抽象声明一个接口,然后定义实现该接口的可重用模块组件。

  模块化组件构成了模块化人工智能重用的核心,每种类型的组件只实现一次,就能重复使用。为了实现这一点,每种类型的组件都需要知道如何从配置中加载自己,因此,定义模块化组件的特定实例功能的所有参数都可以在数据中定义。

  介绍中的距离示例,GAIA通过提供距离考量模块(Distance consideration)使距离评估这一功能可复用。一个特定的距离考量模块中的配置,可以是通过确定位置坐标来测量它们之间的距离。例如,狙击手选择要射击的目标时,可能需要距离考量模块来评估每个潜在目标。这种计算可能被配置成只允许狙击手射击距离50米到500米的目标,且优先选择更近的目标。然后,这种考量可以与其他考量结合起来,这些考量可以衡量未来目标是朋友还是敌人,目标有多少掩护,是否是高价值目标(如军官),等等。

  此外,考虑并不是孤立地工作,而是在配置中使用其他概念抽象。例如,使用目标指定这两个位置,并使用权重函数指定将距离与其他注意事项结合起来的方式。目标和权重函数是GAIA中另外两个概念抽象。

  这种方法的一个优点是它具有高度的可扩展性。随着开发的进展,您发现了应该在决策中权衡的新因素,您可以创建新类型的考量来评估这些新因素,并简单地将它们放入其中。因为它们与所有其他考量共享相同的接口,所以无需更改其他任何内容。

  这不仅让开发、迭代人工智能更迅速 ,而且还降低了引入bug的可能性(因为所有更改都局限于要添加的考虑事项,可以单独测试)。因此,在开发周期末进行较大的改动会更安全一些,游戏逻辑和QA反馈都确定好之后,就能在开发末期真正的改进AI。

  这种快速确定、简单复用基础功能模块的开发方式大大减少了开发时间和开发成本。我们已经在几个项目中使用了模块化人工智能,并取得了巨大的成功,其中只有几个月的时间来实现整个人工智能,包括一个在不到4个月的时间内实现人工智能的游戏,该游戏后来售出了数百万份。

8.3.3 An Example Character: The Sniper

  在本章中,我们将以狙击手角色为例,他基于专门为战斗而创建出来的角色为模板来构建,但不会完全相同。一般来说,狙击手应该等到杀戮区(碰巧是一个户外市场)有敌人,然后每隔一两分钟就开枪,只要市场上还有敌人可打,但前提是(a)它没有受到攻击,(b)它有明确的撤退线。如果它受到攻击,它就会试图撤退,但如果它的撤退线被封锁,它就会更积极的反击敌人,尽最大努力去攻击目标(不管他们是否在杀伤区)。此配置的总体结构如图8.1所示。

  在其顶层决策逻辑中,我们的狙击手只有四个选择:狙击杀伤区内的目标,撤退,交战时反击,或者躲起来等待其他选择。在这些选择之间进行决定应当是简单的,所以简单的推理机就能起作用。GAIA包含一个基于规则的推理机,它的工作原理与BT中的选择节点非常相似,也就是说,它只是按照指定的顺序评估其选项,并在当前情况下获取第一个有效的选项。在这种情况下,基于规则的推理器可以设置如下:

  • 如果狙击手受到攻击,且撤退路线不受阻,则撤退。

  • 如果狙击手受到攻击,反击!

  • 如果狙击手的撤退路线是畅通无阻的,杀戮区域有个有效目标,且离狙击手开最后一枪已经过去一两分钟了,那就开枪狙击这个目标。

  • 躲藏

  这些选择都不可能包含具体行动。例如,撤退需要人工智能选择一条路线,沿着这条路线移动,观察敌人,对沿途敌人做出反应,等等。反击需要人工智能选择目标,选择要攻击的位置,瞄准并开火,重新装弹,等等。躲藏要求人工智能从目标的视野中消失,且要时刻观察目标是否进入视野中。

  因此,一旦顶层推理机选择了一个选项(如Snipe),该选项的子推理机将评估其自己的选项。在图8.1中,我们可以看到狙击手选项,例如,使用序列推理器逐步完成射击过程,首先进入适当的姿势(例如,“俯卧”姿势),然后举起武器,暂停(模拟瞄准),最后开火。

8.4 GAIA Infrastructure

  在深入研究不同的概念抽象和模块化组件之前,了解周围的基础设施是有帮助的。本节概述了主要的单例、数据存储和其他对象,这些对象本身并不模块化,但支持模块化组件之间的评估和通信。

8.4.1 The AIString Class

  字符串非常有用,但它们也占用了不合理的空间,而且比较起来很慢。这个问题有很多解决方案;GAIA的是使用djb2散列函数(http://www.cse.yorku.ca/~oz/hash.html)为字符串生成64位哈希,然后保留包含所有原始字符串(如std::strings)的全局字符串表。这允许GAIA进行恒定时间比较,并以64位的方式存储字符串的副本。它还允许GAIA在对字符串进行散列时对其进行小写转换,这样比较就不区分大小写(这使它们更适合设计人员)。另一方面,它有一个预先的性能开销,并对使用的每个字符串(不管字符串本身是否是临时的)进行永久复制,因此GAIA仍然在AIString没有意义的地方(例如调试输出)使用char*和std::string。

  值得注意的是,没有哈希函数是一个保证。如果采用这种方法,最好有一个检查散列冲突的断言(assert);只要在每次散列字符串时查看字符串表,并确保存储的字符串与刚刚散列的字符串相同。

8.4.2 Factories

  GAIA使用工厂模式来实例化构成AI的所有模块化对象。换句话说,定义考量(consideration)的配置部分将包含在单个XML节点中。GAIA通过将XML节点(以及一些支持信息,例如指向此配置将控制的NPC的指针)传递到AIConsiderationFactory来创建实际的C++考量对象,该工厂类实例化并初始化适当类型的对象。

  GAIA的工厂系统是本书前一章的主题,因此我们不会在这里重复细节(Dill 2016)。

8.4.3 The AIDataStore Base Class

  数据存储类是一个AIString-indexed哈希表,可以存储任何类型的数据。GAIA使用它们作为黑板(blackboards)的基础,也作为所有不同类型实体的基础。结果,单个模块化组件可以在黑板和(或)实体之间存储、共享和检索信息,而不需要其他人工智能知道存储了什么,甚至不知道信息的类型。这允许数据被存放在配置表中,在执行操作的时候引擎直接读取配置表,甚至允许游戏引擎将数据传递给自身。

  当然,它也允许人工智能组件彼此共享数据,我们将在下面的狙击手配置中看到一个例子。

  实现这种散列表的方法有很多,而GAIA的方法并不特别,因此我们将跳过实现细节。不过,值得一提的是,由于数据存储本质上是全局内存,因此它们存在名称冲突的风险(即,两组不同的组件都试图使用相同的名称存储数据)。经验表明,只要您有一个合理的描述性命名约定,这通常不是一个问题。尽管如此,如果存储的数据类型不是预期的类型,GAIA应该要有断言(assert)来发出警告。这不会捕捉到所有可能的名称冲突,但能防范大部分情况。

  GAIA目前有两种黑板和三种实体,如下所述。

8.4.3.1 The AIBlackboard_Global Data Store

  这个游戏有一个单独的全局黑板,它可以作为每个人工智能组件都可以使用的信息的中央存储库,而不管这个组件属于哪个角色,或者这个角色在哪一边。

8.4.3.2 The AIBlackboard_Brain Data Store

  每一个人工智能控制的角色的大脑里都有一块黑板。大脑黑板允许组成该角色的人工智能的组件之间互相交流。

8.4.3.3 The AIActor Data Store

  每个NPC都是一个演员。游戏存储了人工智能需要的关于角色的所有信息(如位置、方位、可用武器等),人工智能组件可以根据需要查找这些信息。演员还包含一个AIBrain,其中包含顶层推理机和该角色的所有决策逻辑。

  在一些项目中,演员被用来代表每一个角色,不管人工智能控制与否。在这些情况下,非人工智能控制的演员可能没有大脑,或者,如果他们在人工智能和玩家控制之间来回切换,他们将有大脑,但当人工智能不在控制中时,大脑将被禁用。

8.4.3.4 The AIContact Data Store

  如上所述,有两种方法可以跟踪NPC对游戏中其他角色的了解。首先是用演员来代表每个角色,让每个NPC大脑中的AI组件直接访问其他角色的演员。

  这是可行的,但这要么意味着所有的npc都将拥有全部完整的信息,要么意味着每个AI组件都必须正确地检查它们是否应该知道某个特定的信息。此外,即使人工智能组件进行这些检查,它仍然意味着他们所知道的一切都是正确的。例如,我们只想让人工智能知道敌人的大概位置,而不是准确的位置,这时候就会变得十分困难。

  另一种选择是让每个npc为它所知道的其他角色创建一个联系表,并将其对该NPC的了解存储在联系表上。例如,想象一个角色扮演游戏中,玩家偷了一套制服,以便从敌对的警卫身边溜走。每个守卫都会有一张联系表来存储他们对玩家的认识,如果守卫被愚弄了,那么该联系表会将玩家列为他们的一方,即使玩家实际上是敌人。这使得每个NPC都能对其所感知的角色有自己的认知,但这也意味着AI必须为每个角色存储大量冗余的信息副本。

  这里没有一个正确的答案,这就是为什么GAIA支持这两种方法-最好的方法满足当前项目需求的那个方法。

8.4.3.5 The AIThreat Data Store

  人工智能应该要能够对所有具有威胁的事物做出反应。威胁可以是敌人角色,但也可以包括更抽象的东西,例如最近的爆炸,或子弹撞击的位置。这使得角色能够对狙击手射出的子弹引起的冲击波做出反应,即使他们实际上并不知道射击者的情况,或者根据子弹撞击的地点而不是子弹射出的方向,来突破包围。与联系表一样,威胁是存储在大脑黑板上的,但威胁并不是每个项目都使用。

8.4.4 Singletons

  Singletons提供了全局可访问的管理器。只需调用该类的静态Get()函数,就可以从代码库中的任何位置访问Singleton。还可以通过调用Set()将任何Singleton的默认实现替换为项目特定的版本(必须是子类)。

8.4.4.1 The AIManager Singleton

  AI管理器负责存储所有的演员(actors)。它还有一个Update()函数负责更新所有的actors和大脑。

8.4.4.2 The AISpecificationManager and AIGlobalManager Singletons

  正如我们所说,GAIA是数据驱动的。npc的所有决策都存储在它的配置中,用XML表示。规范管理器负责加载、解析和存储所有配置。然后,当游戏创建NPC的大脑时,它指定角色应该使用的配置的名称。

  数据和代码都可能会有重复的现象。GAIA通过允许配置包含全局变量(globals)来部分解决了这个问题,全局变量是可以在配置内或所有配置中重用的组件规范。全局变量由全局管理器存储。关于全局变量已经在前一章的工厂模式中讨论过了(Dill 2016)。

8.4.4.3 The AIOutputManager Singleton

  良好的调试输出是人工智能开发的关键。GAIA通常混合了日志消息、警告、错误和断言,以及描述当前决策的“状态文本”(以及,适合在引擎中直接显示在有问题的NPC旁边)。输出管理器负责处理所有这些消息,将它们路由到正确的位置,启用/禁用它们,等等。

8.4.4.4 The AITimeManager Singleton

  不同的游戏引擎(和不同的游戏)以不同的方式处理游戏时间。时间管理器只有一个函数GetTime(),在整个人工智能中使用它来实现冷却和最大持续时间等功能。

  内置的实现只是从CPU获取系统时间,但是大多数项目实现了自己的时间管理器,它提供了游戏中的时间。

8.4.4.5 The AIBlackboard_Global Singleton

  如上所述,全局黑板是可用于在游戏和AI之间和/或AI组件之间传递信息的共享内存空间。它是一个单例,可以在全局访问,也可以让项目实现他们自己的版本,这个版本可以与从游戏引擎共享的数据更紧密地耦合在一起。

8.4.4.6 The AIRandomManager Singleton

  随机数是许多游戏的核心,无论是在人工智能内部还是外部。随机管理器包含获取随机值的函数。默认实现使用Jacopin在本卷其他地方描述的dual-LCG方法 (Jacopin 2016),但与其他单例一样,单个项目可以用自定义实现来替换此方法,如果它们愿意,可以使用一些不同的RNG实现。例如,我们有一个单元测试项目,它的RNG总是返回0,这使得编写确定性测试更加容易。

文章来源:http://www.gameaipro.com/  

如侵犯版权,请联系译者删除。   

读者若需要转载,请注明出处。   

若有错误,欢迎指正。

模块化的AI(上) | Game AI Pro的评论 (共 条)

分享到微博请遵守国家法律