虚幻引擎人工智能实用指南-全-

虚幻引擎人工智能实用指南(全)

原文:zh.annas-archive.org/md5/26f2a3caf627def6200b66969d3618b0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

学习如何应用 AI 至关重要,它可以在开发传统、教育或任何其他类型的游戏时将乐趣因素提升到新的水平。虚幻引擎是一个强大的游戏开发引擎,允许你创建 2D 和 3D 游戏。如果你想使用 AI 来延长游戏寿命并使其更具挑战性和趣味性,这本书就是为你准备的。

本书从将人工智能AI)分解为简单概念开始,以获得对其的基本理解。通过各种示例,你将实际操作实现,旨在突出与 UE4 中游戏 AI 相关的关键概念和功能。你将学习如何通过内置 AI 框架构建每个流派(例如 RPG、策略、平台、FPS、模拟、街机和教育游戏)的可信角色。你将学习如何为你的 AI 代理配置导航环境查询感官系统,并将其与行为树结合,所有这些都有实际示例。然后,你将探索引擎如何处理动态人群。在最后一章中,你将学习如何分析、可视化和调试你的 AI 系统,以纠正 AI 逻辑并提高性能。

到本书结束时,你对虚幻引擎内置 AI 系统的 AI 知识将深入且全面,这将使你能够在项目中构建强大的 AI 代理。

本书面向对象

如果你是一位在虚幻引擎中有些经验的游戏开发者,现在想要理解和实现虚幻引擎中的可信游戏 AI,这本书就是为你准备的。本书将涵盖蓝图和 C++,让不同背景的人都能享受阅读。无论你是想构建你的第一款游戏,还是想作为游戏 AI 程序员扩展你的知识,你都会找到大量关于游戏 AI 的概念和实现方面的精彩信息和示例,包括如何扩展这些系统的一些内容。

本书涵盖内容

第一章,《在 AI 世界迈出第一步》,探讨了成为 AI 游戏开发者的先决条件以及 AI 在游戏开发流程中的应用。

第二章,《行为树和黑板》,介绍了在虚幻 AI 框架中使用的两种主要结构,这些结构用于控制游戏中的大多数 AI 代理。你将学习如何创建行为树以及它们如何在黑板中存储数据。

第三章,《导航》,教你如何让代理在地图或环境中导航或找到路径。

第四章,《环境查询系统》,帮助你掌握制作环境查询,这是虚幻 AI 框架的空间推理子系统。掌握这些是实现在虚幻中可信行为的关键。

第五章,代理意识,处理 AI 代理如何感知世界和周围环境。这包括视觉、听觉,以及通过扩展系统可能想象到的任何其他感官。

第六章,扩展行为树,通过使用蓝图或 C++扩展行为树,带你完成 Unreal 的任务。你将学习如何编程新的任务装饰器服务

第七章,人群,解释了如何在提供一些功能性的 Unreal AI 框架内处理人群。

第八章,设计行为树 – 第 I 部分,专注于如何实现行为树,以便 AI 代理可以在游戏中追逐我们的玩家(在蓝图和 C++中)。本章,连同下一章,从设计到实现探讨了这一示例。

第九章,设计行为树 – 第 II 部分,是上一章的延续。特别是,在我们下一章构建最终的行为树之前,我们将构建最后缺失的拼图(一个自定义的服务)。

第十章,设计行为树 – 第 III 部分,是上一章的延续,也是设计行为树系列的最后一部分。我们将完成我们开始的工作。特别是,我们将构建最终的行为树并使其运行。

第十一章,AI 调试方法 – 记录日志,检查我们可以用来调试 AI 系统的一系列方法,包括控制台日志、蓝图中的屏幕消息等等。通过掌握日志记录的艺术,你将能够轻松跟踪你的值以及你正在执行的代码的哪个部分。

第十二章,AI 调试方法 – 导航、EQS 和性能分析,探讨了 Unreal 引擎内集成的 AI 系统的一些更具体的工具。我们将看到更多与 AI 代码相关的性能分析工具,以及可视化环境查询导航信息的工具。

第十三章,AI 调试方法 – 游戏调试器,带你探索最强大的调试工具,也是任何 Unreal AI 开发者的最佳朋友——游戏调试器。本章将更进一步,通过教授如何扩展这个工具来定制它以满足你的需求。

第十四章,超越,以一些关于如何探索本书中提出(以及其他)概念的建议以及一些关于 AI 的想法作为总结。

要充分利用这本书

熟练使用 Unreal Engine 4 是一个重要的起点。本书的目标是将那些使用这项技术的人带到他们能够足够舒适地掌握所有方面,成为项目中的技术领导者和推动者的水平。

下载示例代码文件

您可以从这里下载本书的代码包:github.com/PacktPublishing/Hands-On-Artificial-Intelligence-with-Unreal-Engine

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,这些资源可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788835657_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText: 表示文本中的代码单词。以下是一个示例:“接下来要实现的事件是OnBecomRelevant(),并且只有在服务变得相关时才会触发”

代码块设置如下:

void AMyFirstAIController::OnPossess(APawn* InPawn)
{
  Super::OnPossess(InPawn);
  AUnrealAIBookCharacter* Character = Cast<AUnrealAIBookCharacter>(InPawn);
  if (Character != nullptr)
  {
    UBehaviorTree* BehaviorTree = Character->BehaviorTree;
    if (BehaviorTree != nullptr) {
      RunBehaviorTree(BehaviorTree);
    }
  }
}

当我们希望您注意代码块的特定部分时,相关的行或项目将以粗体显示:

void AMyFirstAIController::OnPossess(APawn* InPawn)
{
  Super::OnPossess(InPawn);
  AUnrealAIBookCharacter* Character = Cast<AUnrealAIBookCharacter>(InPawn);
  if (Character != nullptr)
  {
 UBehaviorTree* BehaviorTree = Character->BehaviorTree;
 if (BehaviorTree != nullptr) {
 RunBehaviorTree(BehaviorTree);
 }
  }
}

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从行为树变量中的下拉菜单中选择 BT_MyFirstBehaviorTree。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com与我们联系。

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问 packt.com

第一部分:虚幻引擎框架

在本节中,我们将深入探讨内置的虚幻引擎 AI 框架。我们将从行为树和黑板是什么开始,然后继续学习关于导航系统、环境查询系统和感知系统的内容。在本节的最后,我们还将了解虚幻引擎如何处理大量人群,以及我们如何通过创建自定义任务、装饰器和服务来扩展(在蓝图和 C++中)我们的行为树。

本节将涵盖以下章节:

  • 第一章,在 AI 世界迈出第一步

  • 第二章,行为树和黑板

  • 第三章,导航

  • 第四章,环境查询系统

  • 第五章,代理意识

  • 第六章,扩展行为树

  • 第七章,人群

第一章:在 AI 世界的第一步

从青铜巨人塔洛斯到符号系统和神经网络:AI 是如何在视频游戏中被塑造和使用的。

欢迎读者,欢迎来到我们人工智能之旅的开始,或者简称为 AI。你有没有想过那些在《魔兽世界》中辛勤工作的平民是如何探索复杂的地图的?或者,那些在《吃豆人》中活泼的幽灵是如何把你带到任何地方的?或者,也许你的对手在《最终幻想》中是如何优化攻击来屠杀你的团队的?

来自《最终幻想 XV》[Square Enix, 2016]的战斗截图。

那你就来对地方了!

在本章中,我们将探讨成为 AI 游戏开发者的先决条件以及 AI 在游戏开发流程中的应用。然后,我们将回顾 AI 的一般历史和在视频游戏中的历史,了解众多杰出人士的联合努力是如何构建出我们今天所知道的 AI。之后,我们将讨论 Unreal 引擎下的 AI 框架,因为本书将专注于 Unreal。

最后,我们将规划我们的旅程,并对本书不同章节所涉及的主题有一个总体了解。

在开始之前...

...我想回答一些你们中的一些人可能已经有的问题。

这本书考虑了 Blueprint 和 C++吗?

本书将解释这两者,所以请放心。

如果你不知道 C++,你可以跟随本书的 Blueprint 部分,如果你愿意,也可以尝试 C++部分。

如果,另一方面,你是一个更喜欢 C++的程序员,那么请不要担心!本书将解释如何在 Unreal 中使用 C++处理 AI。

关于 AI 的书有很多,我为什么应该选择这本书?

不同的书解释了 AI 的不同方面,而且它们往往不是相互排斥的,而是相互补充的。

然而,这本书的主要亮点在于它很好地平衡了 Unreal 中存在的不同 AI 系统的理论以及实际应用,因为整本书都充满了具体的例子。

这本书提供测试项目/材料来工作吗?

绝对,是的。你将能够从以下链接下载本书的内容:hog.red/AIBook2019ProjectFiles(链接区分大小写)。

我已经在使用 Unreal Engine 进行人工智能开发,这本书对我有帮助吗?

这一切都取决于你的知识水平。实际上,本书的第一部分,我们将主要讨论内置在虚幻引擎中的 AI 框架以及如何使用它。如果你在虚幻引擎 AI 方面有一些经验,这可能是你更熟悉的部分。然而,本书将深入探讨这些主题,即使是专家也能找到一些有用的提示。相反,第二部分将讨论游戏 AI 的一些调试方法,并解释如何扩展它们(主要使用 C++)。请随意查看大纲,并决定这本书是否适合你。

我已经在使用另一个游戏引擎,这本书对我还有用吗?

好吧,尽管我很想说我写的是一本关于 AI 的通用书籍,但这并不是——至少不是完全如此。尽管主要焦点仍然是 AI 的主要概念,但我们将探讨如何在虚幻引擎中实现它们。然而,这本书将大量依赖虚幻引擎内置的 AI 框架。因此,我鼓励你阅读更多关于 AI 的通用书籍,以获得更好的理解。另一方面,你总是可以尝试。也许,通过理解这里的一些概念,其他书籍会更容易阅读,你将能够将这种知识转移到你选择的任何游戏引擎中。

我是一个学生/教师,这本书适合在课堂上教学吗?

绝对是的。我知道找到好的资源对于教学来说有多重要,我写这本书正是出于这个目的。因此,无论你是学生还是教师,你都会在每一章的末尾找到一个部分,其中包含一些练习,你可以通过这些练习提高你的技能(或者如果你是教师,你可以将这些练习推荐给你的学生)。此外,你还可以在这里找到一些更综合的材料:hog.red/AIBook2019LearningMaterial(链接对大小写敏感)。

这本书是否会涵盖关于虚幻引擎及其所有系统的 AI 的方方面面?

尽管我尽力详细描述每个系统,但涵盖所有内容是不可能的任务,这也归因于如此大型引擎的复杂性。然而,我有信心地说,这本书涵盖了与虚幻引擎中每个 AI 系统相关的几乎所有方面,包括如何扩展内置系统以及如何高效地进行调试。因此,我确实可以说这本书非常全面。

前置条件

由于本书面向的是刚开始在游戏开发中使用 AI 的人群,因此我不会假设读者有任何关于 AI 的先验/背景知识。然而,请考虑以下几点:

  • 蓝图用户:你应该熟悉蓝图编程,并了解蓝图图的一般工作原理。

  • C++ 用户:您应该熟悉编程,特别是 C 家族语言(如 C、C#、C++ 或甚至 Java),尤其是 C++,因为这是 Unreal Engine 使用的语言。熟悉 Unreal Engine C++ API 是一个很大的加分项,尽管不是强制性的。所以,即使您不是专家,也不要担心——跟随步骤,您将会学到。

此外,如果您对矢量数学和物理运动学原理有所了解——至少是视频游戏中常用的那些——那就太好了。无论如何,如果您对这些内容不太熟悉,也不要过于担心,因为这本书并不要求您必须掌握;然而,如果您在寻找人工智能开发者的工作,这将是一个加分项。

安装和准备软件

在您继续阅读之前,让我们安装我们需要的软件。特别是,我们需要 Unreal Engine 和 Visual Studio。

Unreal Engine

让我们来谈谈 Unreal Engine。毕竟,这是一本关于如何在这样一个优秀的游戏引擎中开发游戏人工智能的书。

Unreal Engine 是由 Epic Games 开发的一款游戏引擎。它最初于 1998 年发布,如今由于它强大的功能,它是使用最广泛的(开源)游戏引擎之一(与 Unity 并列)。下面的截图显示了 Unreal Engine 的主界面:

图片

Unreal Engine 主界面截图

我们需要安装最新版本的 Unreal Engine。您可以通过访问 www.unrealengine.com/en-US/what-is-unreal-engine-4 来找到它。除非您是从源代码获取的 Unreal Engine (docs.unrealengine.com/en-us/Programming/Development/BuildingUnrealEngine),否则您将安装 Epic Launcher。如果您是 Blueprint 用户,并且不打算使用 C++,那么这已经足够了。如果您将使用 C++,您需要执行几个额外的步骤。

在安装引擎时,如果您使用的是 C++,您需要检查一些选项。特别是,我们需要确认我们既有 “Engine Source” 也有 “Editor symbols for debugging”,如下面的截图所示:

图片

通过这样做,我们能够导航 C++ 引擎源代码,并在发生崩溃时拥有完整的调用栈(这样您就会知道出了什么问题)。

Visual Studio

如果您使用的是 Blueprint,您不需要这样做——这仅适用于 C++ 用户。

实际上,我们需要一个集成开发环境(IDE)来编辑我们的 C++ 代码。我们将使用 Visual Studio,因为它与 Unreal 集成得很好。您可以通过官方网站 www.visualstudio.comvisualstudio.microsoft.com/vs/ 免费下载 Visual Studio Community Edition

你可能也会发现这个关于如何设置Visual Studio以便与 Unreal Engine 一起工作的简短指南很有用:docs.unrealengine.com/en-us/Programming/Development/VisualStudioSetup

一旦你安装好所有东西并准备就绪,我们就可以继续本章的其余部分。

如果你是一名MacOS用户,有一个适用于MacOSVisual Studio版本。你可以使用那个版本。或者,你可能能够使用 XCode

成为 AI 游戏开发者

你曾梦想过成为一名 AI 游戏开发者吗?或者只是能够编写"智能"程序?那么这本书就是为你准备的!

然而,我必须建议你,这并不是一件容易的任务。

游戏开发和设计是周围最广泛的艺术作品之一。这是由于将游戏带到生命中所需要的专业知识量很大。你只需看看游戏中的最终字幕就能得到这个想法。它们是无穷无尽的,包含了许多人在各种角色上为游戏投入了大量时间的名字。AI 开发是这个大过程中的一个核心部分,它需要多年的时间来掌握,就像生活中的大多数事情一样。因此,迭代是关键,这本书是一个开始的好地方。

成为 AI 游戏开发者意味着什么

首先,你需要掌握数学、物理和编程。此外,你很可能会在一个跨学科团队中工作,这个团队包括艺术家、设计师和程序员。实际上,你可能需要与现有的专有软件技术合作,并且需要你能够构建新技术以满足项目的技术要求。你将被要求研究编码技术和算法,以便你能够跟上游戏行业的技术发展和进步,并识别技术和发展风险/障碍,并生成解决方案来克服已识别的风险。

另一方面,你将能够赋予视频游戏中的角色和实体生命。在你可能经历的所有挫折之后,你将是第一个提供帮助的人,或者更好的是,在游戏中生成智能行为。这需要时间,而且相当具有挑战性,所以在早期阶段不要对自己太苛刻。一旦你在游戏中实现了可以独立思考的真正 AI,这将是一个值得奖励自己的成就。

对于 AI 的初学者来说,这本书将帮助你为那个目标奠定第一块基石。对于专家来说,这本书将提供一份有用的指南,帮助你刷新 Unreal 中的不同 AI 系统,并深入探索可能有助于你工作的功能。

游戏开发过程中的 AI

游戏开发流程可能会因你访问的哪个工作室而大不相同,但它们都指向了视频游戏的创作。这不是一本关于流程的书,所以我们不会探索它们,但了解 AI 大致的位置是很重要的。

事实上,AI 与游戏开发流程的许多部分相交。以下是一些主要的部分:

  • 动画:可能会让一些人感到惊讶,但关于这个主题正在进行很多研究。有时,动画和 AI 会重叠。例如,开发者需要解决的一个问题是如何以程序化的方式为角色生成数百个动画,这些动画可以表现得非常逼真,以及它们如何相互交互。实际上,解决逆运动学(IK)是一个数学问题,但选择无限多解中的哪一个来实现目标(或者只是提供一个逼真的外观)是一个 AI 任务。在这本书中,我们不会面对这个具体问题,但最后一章将提供一些指向你可以了解更多信息的地点。

  • 关卡设计:如果一个游戏能够自动生成关卡,那么 AI 在这个游戏中就扮演着重要的角色。程序性内容生成(PCG)在游戏中是一个热门话题。有些游戏完全基于 PCG。不同的工具可以用来程序化生成高度图,帮助关卡设计师实现看起来逼真的景观和环境。这确实是一个值得深入探讨的广泛话题。

  • 游戏引擎:当然,在游戏引擎内部,有很多 AI 算法在发挥作用。其中一些是针对代理的特定算法,而另一些则只是改进了引擎的功能和/或任务。这些构成了最广泛的类别,它们可以从简单的调整贝塞尔曲线的算法到实现用于动画的行为树或有限状态机。在底层,这里有很多事情在进行。在这本书中,我们将探讨一些这些概念,但要记住的是,一个算法可以被调整来解决不同领域中的类似问题。实际上,如果有限状态机(FSMs)被用来做出决策,为什么不用它们来“决定”播放哪个动画?或者为什么不甚至处理整个游戏逻辑(即 Unreal 引擎中的蓝图可视化脚本)?

  • 非玩家角色NPCs):这是在游戏中使用 AI 的最明显例子,也是玩家最明显的 AI(我们将在第十四章“超越”中探讨 AI 与玩家之间的关系)。这本书的大部分内容都集中在这一点上;也就是说,从移动角色(例如,使用寻路算法)到做出决策(即使用行为树),或者与其他 NPC 合作(多代理系统)。

很遗憾,我们在这本书中没有足够的空间来处理所有这些主题。因此,我们将只关注最后一部分(NPCs),并探索内置在 Unreal 中的 AI 框架。

一点历史

在我们开始这段旅程之前,我相信对人工智能和游戏人工智能的历史有一个大致的了解可能会很有益。当然,如果你是一个更倾向于动手操作、迫不及待想要开始编程人工智能的人,你可以跳过这部分内容。

什么是人工智能?

这是一个非常有趣的问题,它没有唯一的答案。实际上,不同的答案会引导我们了解人工智能的不同方面。让我们探索一些(众多)不同学者(按时间顺序)给出的定义。

实际上,在他们的书中,Russell 和 Norvig 将这些特定的定义组织成了四个类别。以下是他们的框架:

图片

Russell 和 Norvig 的四个类别。左上角:“像人类一样思考的系统”。右上角:“理性思考的系统”。左下角:“像人类一样行动的系统”。右下角:“理性行动的系统”。

我们没有时间详细探讨“什么是人工智能?”这个问题,因为这可以单独填满一本书,但本书的最后一章也将包括一些哲学参考,你可以在这里扩展你对这个主题的了解。

回顾过去

对于一些人来说,人工智能的故事始于计算机之前。事实上,甚至古希腊人也假设了智能机器的存在。一个著名的例子是青铜巨人塔洛斯,它保护克里特城免受入侵者。另一个例子是赫菲斯托斯的金色助手,它们帮助上帝在火山锻造中工作,还有独眼巨人。在 17 世纪,勒内·笛卡尔写下了关于能够思考的自动机,并相信动物与机器不同,可以用滑轮、活塞和凸轮复制。

然而,这个故事的核心始于 1931 年,当时奥地利逻辑学家、数学家和哲学家库尔特·哥德尔证明了所有一阶逻辑中的真命题都是可推导的。另一方面,这在高阶逻辑中并不成立,其中一些真(或假)命题是无法证明的。这使得一阶逻辑成为自动推导逻辑后果的良好候选者。听起来很复杂吗?嗯,你可以想象这对他的传统主义同代人来说听起来是怎样的。

图片

阿兰·图灵 16 岁时的照片

在 1937 年,英国计算机科学家、数学家、逻辑学家、密码分析家、哲学家和理论生物学家艾伦·图灵,通过停机问题指出了“智能机器”的一些局限性:除非实际运行,否则无法预先判断一个程序是否会终止。这在理论计算机科学中产生了许多后果。然而,根本性的步骤发生在十三年后的 1950 年,当时艾伦·图灵撰写了他的著名论文“计算机与智能”,在其中他讨论了模仿游戏,现在通常被称为“图灵测试”:一种定义智能机器的方法。

在 20 世纪 40 年代,一些尝试试图模拟生物系统:1943 年,麦克洛奇和皮茨为神经元开发了一个数学模型,1951 年,马文·明斯基创建了一台机器,能够用 3000 个真空管模拟 40 个神经元。然而,他们陷入了黑暗。

从 20 世纪 50 年代末到 20 世纪 80 年代初,大量的人工智能研究致力于“符号系统”。这些系统基于两个组件:由符号组成的知识库和一个推理算法,该算法使用逻辑推理来操纵这些符号,以扩展知识库本身。

在这个时期,许多杰出的思想者取得了显著的进步。值得提及的名字是麦卡锡,他在 1956 年在达特茅斯学院组织了一次会议,在那里首次提出了“人工智能”这个术语。两年后,他发明了高级编程语言LISP,在其中编写了第一个能够自我修改的程序。其他引人注目的成果包括 1959 年盖尔伦特的几何定理证明器,1961 年纽厄尔和西蒙的通用问题求解器(GPS),以及由维齐纳鲍姆开发的著名聊天机器人Eliza,这是 1966 年第一款能够用自然语言进行对话的软件。最后,在 1972 年,法国科学家阿兰·科梅拉乌尔发明了PROLOG,标志着符号系统的顶峰。

符号系统导致了众多人工智能技术的产生,这些技术至今仍被用于游戏,如黑板架构、路径查找、决策树、状态机和转向算法,我们将在本书中探讨所有这些内容。

这些系统的权衡在于知识和搜索之间。你拥有的知识越多,你需要的搜索就越少,你搜索得越快,你需要的知识就越少。这甚至已经在 1997 年由沃尔珀特和麦克雷德通过数学证明了。我们将在本书的后面有机会更详细地考察这种权衡。

在 20 世纪 90 年代初,符号系统变得不适用,因为它们证明难以扩展到更大的问题。此外,一些哲学论点反对它们,认为符号系统是有机智能的不兼容模型。因此,开发了受生物学启发的旧技术和新技术。旧的神经网络被从架子上取下来,1986 年 Nettalk 的成功,这个程序能够学会如何朗读,以及同年 Rumelhart 和 McClelland 出版的书"并行分布式处理"。事实上,"反向传播"算法被重新发现,因为它们允许神经网络(NN)真正地学习。

在过去 30 年的 AI 研究中,研究方向发生了新的变化。从 Pearl 在"智能系统中的概率推理"上的工作开始,概率被采纳为处理不确定性的主要工具之一。因此,人工智能开始使用许多统计技术,如贝叶斯网络、支持向量机(SVMs)、高斯过程和马尔可夫隐模型,后者被广泛用于表示系统状态的时态演变。此外,大型数据库的引入为人工智能解锁了许多可能性,并出现了一个名为"深度学习"的新分支。

然而,重要的是要记住,即使人工智能研究人员发现了新的和更先进的技巧,旧的技巧也不应该被丢弃。事实上,我们将看到,根据问题和其规模的不同,特定的算法可以大放异彩。

游戏中的 AI

视频游戏中人工智能的历史与我们之前讨论的内容一样有趣。我们没有时间详细地回顾并分析每一款游戏以及它们如何为该领域做出贡献。对于最好奇的你们,在本书的结尾,你们将找到其他讲座、视频和书籍,你们可以更深入地了解视频游戏中人工智能的历史。

视频游戏中人工智能的第一种形式是原始的,并用于像《乒乓》[Atari, 1972],《太空侵略者》[Midway Games West, Inc., 1978]等游戏。事实上,除了移动球拍试图捕捉球,或者移动外星人向玩家移动之外,我们并没有做更多的事情:

图片

《太空侵略者》的一个截图[Midway Games West, Inc., 1978],其中使用了一种原始形式的人工智能来控制外星人

第一款使用显著人工智能的著名游戏是《吃豆人》[Midway Games West, Inc., 1979]。四个怪物(后来因为 Atari 2600 中的闪烁端口而被称为幽灵)使用有限状态机(FSM)来追逐(或逃离)玩家:

图片

《吃豆人》游戏的一个截图[Midway Games West, Inc., 1979],其中四个怪物使用有限状态机试图捕捉玩家

在 20 世纪 80 年代,游戏中的 AI 并没有太大变化。直到《魔兽世界:兽人 vs 人类》[Blizzard Entertainment, 1994]的引入,路径查找系统才在视频游戏中成功实现。我们将在第三章,导航中探讨导航系统:

图片

《魔兽世界:兽人 vs 人类》[Blizzard Entertainment, 1994]的截图,其中单位(本截图中的兽人步兵和士兵)使用路径查找算法在地图上移动

可能是开始让人们关注 AI 的游戏是《007 黄金眼》[Rare Ltd., 1997],它展示了 AI 如何提升游戏体验。尽管它仍然依赖于 FSM,但创新之处在于角色可以看到彼此,并相应地行动。我们将在第五章,代理意识中探讨代理意识。这当时是一个热门话题,一些游戏将其作为主要游戏机制,例如《盗贼:暗影项目》[Looking Glass Studios, Inc., 1998]:

图片

《007 黄金眼》[Rare Ltd., 1997]的截图,它改变了人们对于视频游戏 AI 的看法

《合金装备固体》[Konami Corporation, 1998]:

图片

《合金装备固体》[Konami Corporation, 1998]的截图,

另一个热门话题是在战斗中模拟士兵的情绪。最早实现情感模型的游戏之一是《战锤:黑暗预兆》[Mindscape, 1998],但直到《全面战争:幕府将军》[The Creative Assembly, 2000],这些模型才在大量士兵中使用并取得了极大的成功,而没有性能问题:

图片

《战锤:黑暗预兆》的截图,这是最早使用士兵情感模型的游戏之一

图片

《全面战争:幕府将军》的截图。士兵的情感模型比《战锤:黑暗预兆》中的更复杂,但仍然成功地用于许多士兵

一些游戏甚至将 AI 作为游戏的核心。尽管最早这样做的一款游戏是《Creatures》[Cyberlife Technology Ltd., 1997],但这一概念在《模拟人生》[Maxis Software, Inc., 2000]或《黑与白》[Lionhead Studios Ltd., 2001]等游戏中更为明显:

图片

《模拟人生》的截图。一个模拟者(角色)正在烹饪,这是游戏中由 AI 驱动的复杂行为的一部分。

在过去的 20 年里,许多 AI 技术已被采用和/或开发。然而,如果游戏不需要高级 AI,你可能会发现仍然广泛使用的有限状态机(FSMs),以及我们将很快在第二章中探讨的行为树黑板

游戏中的 AI – 行业与学术界

当涉及到比较应用于视频游戏的 AI,无论是在学术界还是在工业界,都存在很大的差异。我可以说,两者之间几乎有一场斗争。让我们看看背后的原因。事实上,它们的目标非常不同。

学术界希望创建能够智能思考并在环境中行动以及与玩家互动的游戏 AI 代理

另一方面,游戏行业希望创建看起来能够智能思考并在环境中行动以及与玩家互动的游戏 AI 代理

我们可以清楚地注意到,前者导致更真实的 AI,而后者导致更可信的 AI。当然,商业游戏更担心后者而不是前者。

我们将在第十四章中更详细地探讨这个概念,超越,当我们讨论创建游戏 AI 系统所涉及的心理学和游戏设计时。实际上,为了实现可信的行为,你通常需要尝试并使其尽可能真实。

然而,在更正式的术语中,我们可以这样说,游戏 AI 属于弱 AI(与强 AI相对)的范畴,它专注于以智能的方式解决特定任务或问题,而不是在其背后发展意识。无论如何,我们不会进一步探讨这个问题。

规划我们的旅程

现在是时候开始规划我们的旅程了,在跳入下一章之前。

技术术语

由于对于一些人来说,这是他们第一次进入 AI 领域,因此了解这本书(以及在 AI 中通常使用的)中使用的术语的小型词汇表很重要。我们在过去几页中已经遇到了其中的一些:

  • 代理是能够自主推理以解决特定目标集的系统。

  • 反向链式推理是通过向后工作来追踪问题原因的过程。

  • 黑板是不同代理之间交换数据以及有时甚至在代理本身内部(特别是在虚幻引擎中)交换数据的架构。

  • 环境是代理生活的世界。例如,游戏世界是同一游戏中 NPC 的环境。另一个例子是棋盘,它代表了一个与人类(或其他系统)下棋的系统的环境。

  • 正向链式推理,与反向链式推理相反,是通过向前工作来找到问题解决方案的过程。

  • 启发式是一种解决问题的实用方法,它不保证是最优的,也不足以满足即时目标。当寻找问题的最优解不切实际(甚至不可能)时,使用启发式方法来找到令人满意的解决方案。它们可以被视为在决策过程中减轻认知负担的心理捷径。有时,它可以代表基于代理过去经验的认知(尽管这通常是在先验的基础上给出的)。术语"启发式"源自古希腊,其意义为"找到"或"发现"。

对于更广泛的词汇表,你可以查看维基百科上的一个。以下是链接:en.wikipedia.org/wiki/Glossary_of_artificial_intelligence

自下而上的方法

通常,当一个系统被构建或研究时,有两种主要的方法:自上而下和自下而上。前者从系统的较高层次结构开始,逐渐进入系统的颗粒度细节。后者从基础开始,逐步创建依赖于前者的更复杂结构。两种方法都是有效的,但出于个人偏好,我选择了自下而上的方法来介绍本书的主题。

事实上,我们将从代理如何移动开始,然后理解它如何感知,最后使用这些数据来做出信息化的决策,甚至制定一个计划。这一点反映在这本书的结构和各部分中。

代理模式

由于在这本书中,我们将探讨人工智能代理如何感知、移动、规划和与周围环境交互的不同部分,因此绘制一个为此目的的方案将是有用的。当然,可能会有许多其他方案,它们都是同样有效的,但我相信这个方案对于开始AI 游戏开发特别有用:

图片

本书将要使用的代理模型

由于我们选择了自下而上的方法,我们应该从底部读取模式。我们将更正式地称这个为我们的代理模型

首先,我们可以看到代理总是与游戏世界交换信息,这包括几何、物理和动画,以及它们的抽象。这些信息被用于我们代理模型的所有层级。

从底层来看,我们首先关注的是如何在环境中移动。这是一个可以分解为运动和路径查找的过程(第三章,导航)。沿着链向上,我们可以看到代理感知世界(第四章,环境查询系统和第五章,代理意识),并且基于这种感知,代理可以做出决策(第二章,行为树和黑板*)。有时,在那一刻做出最佳决策可能不会在长期内带来更好的结果,因此代理应该能够提前规划。通常,在视频游戏中,一个 AI 系统(不一定是 NPC)可以控制多个角色,因此它应该能够协调一组角色。最后,代理可能需要与其他代理协作。当然,我们无法在这本书中深入探讨每个主题,但你可以自由地在网上查看,以便更深入地了解某些主题。

最后一点:通常,游戏中的 AI 不会一次性使用所有这些层级;有些只实现其中之一,或者混合使用。然而,在开始操作之前,了解事物的结构是很重要的。

虚幻引擎 AI 框架

尽管其他游戏引擎只提供渲染能力,但虚幻引擎自带了许多实现(并通过插件扩展)。这并不意味着制作游戏更容易,而是我们拥有更多开发游戏所需的工具。

实际上,虚幻引擎也实现了许多人工智能工具。当我们探索它们时,我们可以创建一个这些工具及其相互关联的架构。因此,让我们先了解一下我们将要在哪个层面上操作。这意味着要深入了解虚幻游戏框架(你可以在以下链接中找到更多关于此的信息:docs.unrealengine.com/en-us/Gameplay/Framework)。

存在一个控制器类,它可以分为两个子类。第一个是玩家控制器;正如其名所示,它为游戏和玩家之间提供了一个接口(当然,这本书中并未涵盖,因为我们将会关注 AI 而不是通用的游戏玩法)。第二个类是 AIController,它提供的是我们的 AI 算法和游戏本身之间的接口。

以下图表展示了这些工具以及它们如何相互作用:

图片

这两种控制器都可以拥有一个 Pawn,这可以被认为是一个虚拟化身。对于玩家来说,这可能就是主要角色;对于 AIController 来说,Pawn 可以是被玩家想要击败的敌人。

在这本书中,我们将只关注 AIController,以及所有围绕和在其下为我们的 AI 带来生命力的工具(我们不会涵盖前图中省略的部分)。我们将在稍后的阶段理解我的意思,但关键概念是我们将操作在AIController的层面。

如果你已经对 C++和 Unreal 有些熟悉,你可以查看其类,该类定义在AIController.h文件中,以了解更多关于这个控制器的信息。

我们旅程的草图

既然我们已经对将要使用的架构有了大致的了解,让我们按我们将要面对的主题的顺序(我说的是大致的顺序,因为有些主题会跨越多个章节,并且在我们对 AI 的了解扩展后需要迭代)来分解这本书将要涵盖的内容。

然而,你可以将这本书视为分为三个部分:

  • 第 2-7 章:对不同内置 AI 系统的描述

  • 第 8-10 章:如何使用我们在前几章中探索的 AI 系统的具体示例

  • 第 11-13 章:对游戏 AI 的不同调试方法的描述(因为我相信这部分与了解系统本身同等重要)

让我们详细谈谈这本书将要涵盖的内容。

使用行为树进行决策(第 2、6、8、9 和 10 章)

一旦智能体能够感知其周围的世界,它就需要开始做出决策,这些决策会有后果。某些决策过程可能会变得非常复杂,以至于智能体需要制定一个适当的计划才能成功实现目标。

内置的 Unreal Engine 框架围绕行为树旋转,这占据了本书的大部分内容。当然,这并不排除你在 Unreal 中自行实现其他 AI 系统进行决策的可能性,但通过选择行为树,你将拥有一套强大的工具集,我们将在本书中详细探讨。

导航(第 3 和 7 章)

除非游戏是离散的或回合制的,否则每个 AI 智能体都需要以连续的方式在其自己的环境中移动。Unreal 提供了一个强大的导航系统,允许你的 AI 智能体在环境中轻松导航,从坠落到跳跃,从蹲伏到游泳,到不同类型的区域和不同类型的智能体。

这个系统如此庞大,要全部涵盖它将很困难,但我们将尽力涵盖你开始学习第三章,“导航”所需的所有内容。

环境查询系统(第 4 和 12 章)

环境查询系统 (ESQ) 可以从代理周围的环境中收集信息,从而允许代理据此做出决策。本书专门用一章来介绍这个系统。实际上,它位于第五章,代理意识决策制定之间,并且是已经内置到 Unreal 中的宝贵资源。

代理意识(第五章和第十二章)

代理意识(或感知)涉及赋予 AI 代理感官的能力。特别是,我们将涵盖视觉,这是最常见和最广泛使用的,但也会涉及听觉和嗅觉。

此外,我们将开始探讨这些数据如何被用于高级结构中,以便代理可以相应地行动。

人群(第七章)

当你在地图中拥有许多 AI 代理时,环境会变得容易过于拥挤,各种代理可能会相互干扰。人群系统允许你控制大量 AI 代理(同时它们可以保持个体行为),以便它们可以避免彼此。

设计行为树(第 8、9 和 10 章)

对于 AI 开发者来说,仅仅了解行为树的工作原理是不够的:他们需要知道如何设计它们。实际上,你的大部分工作都是关于创建一个抽象系统来协调所有 AI 代理,然后你才会花剩下的时间来实现它。因此,我们将涵盖一个单一且庞大的示例,展示如何从头开始设计、创建单个部分,并构建一个完整的 行为树

游戏 AI 的调试方法(第 11、12 和 13 章)

一旦你了解了所有不同的 AI 系统,你就可以开始对这些系统进行实验或编写游戏,但如何理解你的 AI 是否按照你的计划执行并且/或者表现良好?调试方法在任何软件中都是关键,但在游戏 AI 中,你还需要视觉调试。因此,Unreal Engine 提供了许多调试方法(包括一些专门针对 AI 的方法),我坚信了解这些方法非常重要。你不仅会学习工具,还会学习如何根据你的需求扩展它们。

超越(第十四章)

本书最后一部分将探讨一些目前在 AI 领域正在进行的激动人心的想法和创新,并为你继续美妙旅程提供灵感。我将介绍一些正在进行的 AI 研究,这些研究被应用于游戏,以及这最终如何对你的游戏产生益处。在这个领域,了解新技术和算法是关键,这样你才能始终保持最新。

为 C++用户启用 AI

如果您作为 C++ 用户阅读此书,当您在项目(或项目的特定模块或插件)中编写 C++ 代码时,您需要确保添加正确的依赖项,以便您能够访问 AI 系统,否则您将遇到编译错误。我们将在下一章中更详细地探讨这一点,当时我们将创建一个项目来插入本书中我们将产生的所有代码。然而,以下是插入/修改 .cs 项目文件的代码行(粗体部分是 AI 运作所必需的):

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", *"GameplayTasks", "AIModule"* });

摘要

在本章中,我们看到了视频游戏中的 AI 世界是多么奇妙。我们探索了视频游戏背后的历史,无论是在学术界还是在工业界。我们在本书中规划了我们的旅程,并解释了它将是什么样子。

现在,是我们准备的时候了,因为从下一章开始,我们将深入实践,直接进入 Unreal Engine。

第二章:行为树和黑板

一个决定我们如何行动的树,一个黑板来记住它!

欢迎来到第二章Chapter 2,行为树和黑板。从这里开始,事情开始变得更有趣,因为我们将会学习如何使用两个主要的虚幻 AI 框架结构。首先,我们将查看行为树并了解它们的主要组件,如任务、装饰器和服务。接下来,我们将学习关于黑板的内容以及如何将其与行为树集成。在完成这些之后,我们将能够设置使用行为树的 AI 控制器,这对于实现本书中其余的技术至关重要。

如你所注意到的,我们首先学习一点理论,然后直接进入实践来理解它是如何运作的。这是我们将在每一章中遵循的模式。所以,让我们开始吧。

在决策领域,有许多可以使用的数学结构。有限状态机(FSM)是一个简单而强大的例子,它能够做出复杂的决策。然而,在游戏人工智能的世界中,还有一个结构可以被非 AI 专家使用:行为树

因此,虚幻引擎的设计选择之一是它内置了对行为树的支持,并且实际上是 AI 框架的主要核心部分。这并不意味着你不能实现其他决策过程或结构,但使用内置的行为树支持将大大有利于你团队的预算(从时间角度考虑)。所以,在你实现在虚幻引擎中的不同决策结构之前,请三思是否这是一个好的决定(当然,行为树可能不是你游戏的最佳选择,但请记住它们有内置支持,可能是一个节省时间的好方法)。然而,你仍然可以在行为树中实现子结构来扩展其功能,但不要急于求成;首先,让我们来了解一下行为树的基础知识。

特别是,在本章中,我们将学习以下主题:

  • 行为树是什么,从更广泛的角度和虚幻引擎的上下文中来看。

  • 在虚幻引擎中,行为树是如何工作的,包括其不同的组件以及它们如何与树交互

  • 什么是黑板以及它如何用于存储行为树的数据

  • 如何通过使用 AI 控制器启动运行行为树,无论是在蓝图还是 C++中

那么,让我们深入探讨!

行为树是如何工作的

考虑到行为树在我们 AI 代理中的作用,最简单的方法是将其想象成一个大脑。它做出决定,并相应地采取行动。它是我们代理中人工智能的处理器。在我们开始之前,如果你在其他环境中对行为树有任何经验,重要的是要理解它们在虚幻引擎中的不同。

如果你想了解更多关于它们如何不同的信息,你可以通过访问以下链接来了解:docs.unrealengine.com/en-US/Engine/AI/BehaviorTrees/HowUE4BehaviorTreesDiffer

然而,在这里强调一个关键的区别是很重要的:虚幻引擎的行为树是从上到下读取的,节点将从左到右执行。在其他环境中,你可能发现顺序相反,即树是从左到右读取的,节点是从上到下执行的。

如果这是你第一次遇到行为树,那么当你阅读下一节时,这将会变得有意义。

数学树的结构

好的,现在是时候了解一个行为树是如何工作的了。首先,正如其名所示,它是一个树,从数学的角度来说。

如果你想要了解更多关于图论中树的信息,你可以查阅以下维基百科页面:[zh.wikipedia.org/wiki/树 _(图论)](https://zh.wikipedia.org/wiki/树 _(图论))。或者,如果你想了解得更深入,你可以查阅以下页面:mathworld.wolfram.com/Tree.html。然而,两个链接中找到的定义都非常数学化,你不需要它们来理解行为树。

需要明确的是,一个(数学)树表达了节点之间的关系。在这个意义上,描述家庭(例如,父母、子女、兄弟姐妹)的技术术语中采用了相同的关系。为了简化对树的了解,你可以想象你的家谱树:每个节点是一个人,连接人们的分支(即关系)是各种人之间的关系。然而,结构还是略有不同。

那么,什么是树?它是一个描述不同节点之间关系的图。

特别是,有一个节点,它是唯一没有父节点的节点。从那里,每个节点可以有一个或多个子节点但只有一个父节点。没有子节点的终端节点被称为叶子节点。以下是一个简单的图表,帮助你理解一般数学树的基本结构:

图片

这可能听起来很复杂,但实际上并不复杂。当我们继续前进并讨论行为树时,事情将会变得有趣起来。

行为树组件

如果你查看官方文档,你会发现有五种类型的节点(任务装饰器服务组合)可供使用,具体取决于你试图创建的行为类型(以及随后 AI 在世界上应该如何行动)。然而,我想以更易于理解的方式重新表述这一点,并希望它更实用。

除了节点之外,唯一的一种不是叶子的节点是组合节点。叶子被称为任务装饰器服务组合节点或任务叶子的附加功能。尽管虚幻引擎允许你将组合节点作为叶子使用,但你不应该这样做,因为这意味着你可以移除该节点,而行为树仍然会以相同的方式工作。以下是一个显示所有不同类型节点的树形结构的示例(实际上,我们将在本书的后面部分构建这个行为树):

图片

当树在执行时,你需要从根节点开始,沿着树向下,从左到右读取节点。你以特定的方式遍历所有不同的分支(组合节点),直到我们达到一个叶子,即任务。在这种情况下,AI 将执行那个任务。重要的是要注意,任务可能会失败,例如,如果 AI 无法完成它。任务可能会失败的事实将有助于理解组合节点的工作原理。毕竟,决策过程只是选择执行哪个任务以更好地实现目标(例如,杀死玩家)。因此,根据哪个任务未能执行(或者,正如我们将看到的,装饰器可以使任务或整个分支失败),组合节点将确定树中的下一个任务。

此外,当你创建你的行为树时,每个节点都可以被选中,你可以在详细面板中找到一些调整节点/叶子的行为设置的选项。此外,由于顺序很重要,行为树中的节点有数字(在右上角)来帮助你理解节点的顺序(尽管它始终是从上到下,从左到右)。以下截图显示了你可以找到这些数字的位置:

图片

-1”的值意味着节点将不会以任何顺序执行,节点周围的色彩会略暗。这可能是由于节点以某种方式未连接到根,因此它是孤立的:

图片

让我们详细看看这些组件,并特别注意组合节点。

对于节点,没有太多可说的。树需要从某个地方开始,所以根节点就是树开始执行的地方。下面是这个节点的样子:

请注意,根节点只能有一个子节点,并且这个子节点必须是组合节点。您不能将任何装饰器服务附加到根节点。如果您选择根节点,它没有任何属性,但您将能够分配一个黑板(我们将在本章后面介绍),如下面的屏幕截图所示:

任务

当我们想到一棵树时,我们通常会想象一个粗大的树干和树枝,树枝上长着叶子。在 UE4 的上下文中,那些“叶子”就是我们所说的“任务”。这些是执行各种动作的节点,例如移动 AI,并且可以附加装饰器服务节点。然而,它们没有输出,这意味着它们本身不参与决策过程,这个决策完全由组合节点负责。相反,它们定义了如果该任务需要执行,AI 应该做什么。

请注意,任务可以像您喜欢的那样复杂。它们可以像等待一段时间那样简单,也可以像在射击玩家的同时解决谜题那样复杂。大任务难以调试和维护,而小任务可能会使行为树变得过于拥挤和庞大。作为一名优秀的 AI 设计师,您应该尝试在任务的大小之间找到平衡,并以一种方式编写它们,以便它们可以在树的各个部分(甚至在其他树中)重复使用。

一个任务可以失败(报告失败)或成功(报告成功),并且它不会停止执行直到报告这两个结果之一。组合节点负责处理这个结果并决定下一步要做什么。因此,一个任务可能需要几个帧来执行,但它只有在报告了失败成功时才会结束。当您继续到第六章,扩展行为树时,请记住这一点,在那里您将创建自己的任务。

任务可以有参数(一旦选择了一个任务,您就可以在详细信息面板中设置这些参数),通常它们是硬编码的值或黑板键引用(关于黑板的更多内容将在本章后面介绍)。

在行为树编辑器中,任务以紫色框的形式出现。在下面的屏幕截图中,您可以查看一些任务的示例以及它们在编辑器中的外观:

Unreal 自带一些内置的任务,可以直接使用。它们是通用的,涵盖了您可能需要的最基本的情况。显然,它们不能针对您的游戏特定,因此您需要创建自己的任务(我们将在第六章,扩展行为树)中查看这一点)。

这里是 Unreal 内置任务的列表:

  • 完成并返回结果:强制任务立即返回一个完成结果(无论是失败还是成功)。

  • 制造噪音:产生一个噪音刺激,由感知系统(这将在第五章,代理意识)使用。

  • 直接朝向移动:与下面的节点类似,但它忽略了导航系统

  • 移动到:使用导航系统(我们将在第三章,导航)将 Pawn 移动到从黑板中指定的位置(我们将在本章后面介绍黑板)。

  • 播放动画:正如其名所示,此节点播放动画。然而,除了例外情况(这也是此节点存在的原因)之外,将动画逻辑和行为逻辑分开是良好的实践。因此,尽量不使用此节点,而是改进你的动画蓝图。

  • 播放声音:正如其名所示,此节点播放声音。

  • 推送 Pawn 动作:执行一个Pawn 动作(不幸的是,我们不会在本章中介绍它们)。

  • 旋转以面对 BB 条目:将 AI Pawn 旋转以面对在 Blackboard 中记住的特定键(我们将在本章后面介绍黑板)。

  • 运行行为:作为一个整体子树运行另一个行为树。因此,可以嵌套行为树以创建和组合非常复杂的行为。

  • 运行行为动态:与前面的节点类似,但在运行时可以更改要执行的()行为树

  • 运行 EQS 查询:执行一个EQS 查询(我们将在第四章中看到它们,环境查询系统)并将结果存储在黑板中。

  • 设置标签冷却时间:通过使用标签为特定的冷却时间节点设置计时器(我们将在本章后面介绍装饰器)。

  • 等待:停止行为一段时间。可以指定一个随机偏差,使等待的时间每次都不同。

  • 黑板时间等待:与上一个节点类似,但时间是从黑板中获取的(关于黑板的更多内容将在本章后面介绍)。

现在我们已经了解了任务节点的工作方式,让我们来探索组合节点,这些节点根据任务返回的是失败还是成功来做出决策。

组合

组合节点是 Unreal 中行为树决策能力核心,理解它们的工作方式是关键。

有三种组合节点:选择器序列简单并行。最后一种最近被添加,你会发现通过使用选择器序列的组合,你将能够覆盖大多数情况。以下是它们的工作方式:

选择器:这种节点会尝试找到其子节点中的一个来执行,这意味着它会尝试找到一个分支(因此作为子节点附加的另一个复合节点)或一个任务(另一个子节点,但它是一个叶子)。因此,选择器从最左边的子节点开始尝试执行它。如果它失败了(无论是任务未能执行,还是整个分支失败了),那么它将尝试第二个最左边的,依此类推。如果一个子节点返回成功,这意味着任务已经完成或整个分支已经完成,那么选择器将向其父节点报告成功,并停止执行其他子节点。另一方面,如果选择器的所有子节点都报告失败,那么选择器也将向其父节点报告失败。在下面的屏幕截图中,你可以看到选择器节点的外观:

图片

序列:这种节点的工作方式有点像选择器的反面。为了向其父节点报告成功,序列的所有子节点都必须报告成功。这意味着序列将开始执行最左边的子节点。如果它成功了,它将继续执行第二个最左边的,依此类推,如果也成功了。如果所有子节点直到最右边的都是成功,那么序列将向其父节点报告一个成功。否则,如果只有一个子节点失败,那么序列将停止执行其子节点,并向父节点报告一个失败。在下面的屏幕截图中,你可以看到序列节点的外观:

图片

简单并行:这是一种特定的复合节点,用于特定情况。实际上,它只能有两个子节点。最左边的子节点必须是一个任务,而最右边的子节点可以是任务复合(从而产生一个子树)。简单并行开始并行执行其所有子节点,尽管最左边的一个被认为是主要的。如果主要的一个失败了,它将报告一个失败,但如果主要的一个成功了,那么它将报告一个成功。根据其设置,简单并行一旦完成了主要任务的执行,可以选择等待子树执行结束,或者直接向父节点报告主要任务的成功或失败,并停止执行子树。在下面的屏幕截图中,你可以看到简单并行节点的外观。请注意,只能拖动两个子节点,其中最左边的一个必须是一个任务(紫色块是可拖动区域):

图片

以这种方式,复合 节点可以根据其子节点报告的内容(失败或成功)来“决定”执行哪些任务,并且 复合 节点会向其父节点报告(要么失败要么成功)。即使根节点(也是一个 复合 节点)的唯一子节点向 根节点 报告成功,那么整个树已经成功执行。一个好的 行为树 设计应该始终允许成功。

装饰器

装饰器 节点(也称为条件)附加到 复合任务 节点上。装饰器 节点决定 行为树 中的某个分支,甚至单个节点是否可以执行。本质上,它们是一个条件;它们检查是否应该发生某事。换句话说,一个 装饰器 可以检查是否值得继续该分支,并且如果根据条件我们确定 任务(或子树)将失败,它可以报告一个预防性的 失败。这将避免装饰器尝试执行一个不可能的任务(或子树)(由于任何原因:信息不足,目标不再相关等...)。

通常,装饰器节点可以充当父节点和其余子树之间的 。因此,装饰器有权力循环子树直到满足某个条件,或者直到特定计时器到期才在子树中执行,甚至可以改变 子树 的返回结果。

例如,想象有一个专门用于杀死玩家的子树(它将做出决策,使代理尝试杀死玩家)。检查玩家是否在范围内(并且不是来自地图的另一侧),或者甚至玩家是否仍然存活,可能会在没有执行该子树的情况下给我们一个预防性的失败。因此,树可以继续执行其他事件或树的其余部分,例如,在另一个子树中,该子树将负责游荡行为。

装饰器 可以有参数(一旦选择了一个 装饰器,你将在 详情面板 中能够设置这些参数),通常它们是硬编码的值或 黑板键引用(关于 黑板 的更多内容将在本章后面介绍)。

几乎每个 装饰器 都有一个复选框在其参数中,允许你反转条件(因此,你将拥有更多的自由,并且可以在树的两个不同部分使用相同的装饰器来执行不同的条件)。

以下截图展示了如何将装饰器附加到 复合 节点上。请注意,每个节点可以有多个装饰器:

对于熟悉其他行为树系统中条件节点的用户来说,重要的是不要将它们与 Unreal Engine 中的任务叶节点混淆。更多信息可以在docs.unrealengine.com/en-us/Engine/AI/BehaviorTrees/HowUE4BehaviorTreesDiffer找到。

与任务类似,Unreal 自带一些内置的装饰器,它们可以立即使用。它们是通用的,涵盖了你可能需要的最基本的情况,但显然,它们不能针对你的游戏或应用程序进行特定化,因此你需要创建自己的装饰器(我们将在第六章扩展行为树中详细讨论)。

这里是 Unreal 内置任务列表:

图片

  • 黑板:检查黑板上的特定键是否设置(或未设置)。

  • 检查演员游戏标签:正如其名所示,它检查是否有由黑板值指定的特定游戏标签(或多个标签)在指定的演员上。

  • 比较 BB 条目:比较两个黑板值,并检查它们是否相等(或不相等)。

  • 组合:这允许你使用布尔逻辑一次性组合不同的装饰器。一旦放置了此装饰器,你可以通过双击它来打开其编辑器。从那里,你将能够使用布尔运算符和其他装饰器构建一个图。

  • 条件循环:只要条件得到满足(无论黑板键是否设置未设置),它将不断循环通过子树。

  • 锥形检查:这检查一个点(通常另一个演员)是否在从另一个点(通常为 AI 代理)开始的锥形内;锥形角度和方向可以更改。其使用的一个例子是,如果你想检查玩家是否在敌人前方——你可以使用此代码来确定此条件。

  • 冷却时间:一旦执行从包含此装饰器的分支退出,将启动冷却计时器,并且此装饰器不允许执行在此计时器过期之前再次进入(它立即报告失败)。此节点用于确保你不频繁地重复相同的子树。

  • 路径是否存在:这使用导航系统(关于这一点,请参阅第三章导航)来确定(并检查)是否存在特定点的路径。

  • 强制成功:正如其名所示,它强制子树成功,无论是否从下面报告了失败(或成功)。这对于在序列中创建可选分支非常有用。

注意,强制失败不存在,因为这没有意义。如果将其放置在选择上,这将使其成为一个序列,如果将其放置在序列上,它将只执行一个子节点。

  • 位于位置:正如其名所示,它检查 Pawn 是否(靠近或)位于特定位置(可选地,使用导航系统)。

  • 是类的 BB 条目:正如其名所示,它检查特定的黑板条目是否属于特定的类。当黑板条目是 Object 类型,并且需要检查黑板内的引用是否属于特定类(或继承自一个类)时,这很有用。

  • 保持圆锥内:与圆锥检查类似,这个装饰器(持续地)检查观察者是否在圆锥内。

  • 循环:正如其名所示,它会在特定子树中循环特定次数(甚至无限次数;在这种情况下,需要其他东西来停止子树的行为,例如另一个装饰器)。

  • 设置标签冷却时间:与同名的任务类似,当这个装饰器变得相关(或者如果你将其想象为一个门,当它被穿越时),它将改变特定标签冷却时间计时器(参见以下节点)。

  • 标签冷却时间:这与冷却时间节点相同,但它与一个标签相关联的计时器。因此,这个计时器可以通过"设置标签冷却时间" 任务和"设置标签冷却时间" 装饰器来改变。

  • 时间限制:正如其名所示,它为子树完成其执行提供时间限制。否则,这个装饰器将停止执行并返回失败

现在我们已经了解了装饰器节点的工作方式,让我们探索行为树中的最后一种节点类型,服务节点,这些节点将连续更新并提供实时信息。

服务

服务节点连接到组合任务节点,并且如果它们的分支正在执行,它们将执行。这意味着只要节点下方有节点连接,无论父-子级别有多少层正在执行——服务也会运行。以下截图将帮助您可视化这一点:

图片

这意味着服务节点是行为树执行的眼睛。实际上,它们会持续运行(如果子树是活跃的),并且可以实时执行检查和/或更新黑板(稍后介绍)的值。

服务节点 确实是为你的 行为树 应用程序量身定制的,因此只有两个默认节点。它们的一个用法示例可能是向子树提供/更新信息。例如,想象一个场景,子树(敌人)试图杀死玩家。然而,即使玩家没有向敌人射击,追求这个目标也是愚蠢的(好吧,这取决于敌人的类型,巨魔可能并不那么聪明)。因此,当子树试图杀死玩家时,子树需要找到掩护来减少敌人受到的伤害。然而,敌人可能在地图上移动,或者玩家可能摧毁了我们 AI 藏身的掩护。因此,子树需要有关最近且最安全的掩护位置的信息,这个位置仍在玩家的射程内(一个 EQS 查询 可以计算出这个信息)。服务可以实时更新这些信息,以便当子树需要使用有关掩护的数据时,它们已经准备好了。在这个特定的例子中,为了找到掩护,在服务上运行 环境查询 是处理这个任务的动态方式(我们将在 第四章,环境查询系统)中探讨这个话题)。否则,服务 可能会检查地图上设计师放置的某些指定点,并评估哪个最适合其给定的动作。

如你所见,服务节点 可以非常强大,但它们也特定于你使用它们的应用程序。因此,它们确实取决于你为你的游戏编写的 AI。

下面的屏幕截图显示了几个服务示例。请注意,服务 可以与 装饰器 一起使用,并且一个 组合节点 可以有多个 服务

截图服务节点 替换了其他 行为树系统 中的传统 并行节点

可用的两个默认 服务(因为你将需要为你的游戏编写自己的服务,我们将在 第六章,扩展行为树)在下面的屏幕截图中显示:

截图

  • 设置默认焦点:当这个节点变为活动状态时,它会自动为 AI 控制器 设置 默认焦点

  • 运行 EQS (定期查询):正如其名所示,它定期运行 环境查询(有关更多信息,请参阅 第四章,环境查询系统),以检查特定的位置或演员。这是我们例子中寻找掩护所需的这种服务。

你将在第四章“环境查询系统”中了解更多关于环境查询的内容。然而,目前你需要知道的是,这是一个用于空间推理的系统,运行这些查询可以在空间中找到具有特定属性的位置(或演员)(在寻找掩护敌人的例子中,最大化这些属性的那个:最近的、最安全的,并且仍然在射程内可以射击玩家)。

现在,我们已经了解了组成行为树的不同类型的节点。现在,是时候探索黑板了!

黑板及其与行为树集成

行为树视为大脑,我们可以将黑板视为其记忆——更具体地说,是 AI 的记忆。黑板存储(并设置)用于行为树使用的键值。

它们被称为黑板,因为在教室里,黑板是一个传递大量信息的地方,但其中大部分信息是学生之间共享的;分发给学生的单独笔记是私人的。你可以将学生想象为不同的任务(和节点),而黑板则是一个共享的数据空间。

黑板相对简单易懂,因为它们只比数据结构复杂一点。唯一的区别在于可以将一个黑板分配给特定的行为树,这个黑板被树中的每个节点共享。因此,每个节点都可以读取和/或写回黑板

对于那些熟悉黑板设计模式的人来说,在虚幻引擎中,它们只是承担了为行为树保存记忆的角色。

它的工作方式就像一个字典(数据结构),其中键对应一个特定的值类型(例如,一个向量、一个浮点数、一个演员等……,甚至是另一个黑板键)。因此,通过使用或回忆键,可以写入或读取相关的值。

黑板的另一个酷炫特性是它们可以通过继承来扩展。这意味着另一个黑板可以作为一个父类,子类将继承所有父类的键值对,再加上子类本身包含的一些特定键值对。

现在我们已经涵盖了理论部分,让我们看看如何创建一个行为树并让它运行。要做到这一点,让我们先创建一个新的项目。

创建我们的 AI 项目

从现在起,我们将通过创建项目来实践,并了解我们关于行为树所学的知识。在本节中,我们将创建一个简单的树,但随着我们在下一章学习更多其他主题,我们将迭代行为树的工具。这将为你提供更好的理解,了解创建出色的行为树所需的工具。然后,在第八章,设计行为树 - 第一部分,第九章,设计行为树 - 第二部分,和第十章,设计行为树 - 第三部分中,我们将专注于如何从头开始创建和设计一个追逐玩家的行为树,这将为你提供关于行为树的实用方法。

为了能够测试本书将要探索的技术,我们需要创建一个项目。通过这样做,你将能够跟随本书中将要涵盖的实践方面。

你可以从模板创建一个新项目。第三人称模板特别适用。实际上,它已经内置了一个角色,可以很容易地被 AI 控制。这意味着你不必过多担心与 AI 无关的细节,例如动画。你可以选择蓝图版本或 C++版本。我将在整个过程中用蓝图和 C++术语解释我们将要覆盖的概念,但请注意,本书中的一些技术如果用 C++编写将运行得更好。因此,我选择了第三人称模板的 C++版本,尽管这个初始选择对我们影响不大(我们是在编写 AI,而不是玩家或游戏玩法)。

最后,我将我的项目命名为UnrealAIBook,如下面的截图所示。你可以在以下链接找到项目文件:hog.red/AIBook2019ProjectFiles(该链接区分大小写):

图片

从 AI 控制器开始行为树

现在我们已经了解了行为树的基本概念及其构成,让我们来创建自己的行为树。回顾前一章,负责拥有并控制棋子的类是 AI 控制器。因此,我们的行为树应该在AI 控制器上运行。

我们有两种方法可以做到这一点。第一种是使用蓝图。通常,即使你是程序员,最好也使用蓝图来创建一个行为树,因为逻辑非常简单,控制器也很简单。另一方面,如果你是 C++爱好者,并且想尽可能多地使用它,即使是对于小任务,不用担心——我会再次重构我们在蓝图中所做的相同逻辑,但这次是在 C++中。无论如何,行为树资产应该在编辑器中创建和修改。你最终要编写的程序节点将不同于默认可用的节点(我们将在本书的后面看到这一点),但树本身始终是在编辑器中制作的。

创建行为树和黑板

要开始,我们需要创建四个蓝图类:AI 控制器角色行为树黑板。我们将在后面介绍 AI 控制器。如果你选择了两个第三人称模板之一,你应该已经有一个角色准备好了。因此,你只需要创建一个行为树和一个黑板

内容浏览器中创建一个新的文件夹,并将其命名为Chapter2。这将有助于保持事物有序。然后,创建一个子文件夹并将其命名为AI。结果,我们可以保持我们的项目整洁,并确保我们不会将本章的内容与其他非人工智能相关的类和/或对象混淆。我们将把为 AI 创建的所有资产放在这个文件夹中。

创建黑板

现在,我们需要添加一个黑板,它应该始终位于AI文件夹中。为此,请转到内容浏览器并选择添加新项 > 人工智能 > 黑板

现在,我们将我们的黑板命名为BB_MyFirstBlackboard。在这里,我使用命名约定,将所有黑板的前缀命名为BB_。除非你有特定的理由不遵循这个命名约定,请使用它。通过这样做,你将与本书的其余部分保持同步。

由于在同一行为树上无法拥有多个黑板,您可以在黑板详情面板中使用继承,父级和子级,如下所示(右边的截图):

截图

创建行为树

让我们通过转到内容浏览器并选择添加新项 > 人工智能 > 行为树来添加一个行为树,如下面的截图所示:

截图

现在,我们将我们的行为树命名为BT_MyFirstBehaviorTree。再次强调,这里我使用特定的命名约定,将所有行为树资产的前缀命名为BT_。请再次遵循命名约定,除非你有特定的理由不这样做。

当你打开行为树窗口时,你会看到一个名为的单个节点,如下所示:

截图

根节点是您的行为树执行开始的地方(从上到下,从左到右)。根节点本身只有一个引用,那就是黑板,因此它不能连接到其他任何东西。它是树的顶端,所有后续的节点都在其下方。

如果您从根节点拖动,您将能够添加组合节点:

图片

对于此,行为树编辑器非常直观。您可以从节点拖动以添加组合任务节点。要添加装饰器服务,您可以在节点上右键单击并选择“添加装饰器...”或“添加服务...”,如图所示:

图片

最后,如果您单击一个节点,可以在详细信息面板中选择其参数(以下截图显示了一个移动到节点的示例):

图片

运行行为树的 AI 控制器

下一步是从AI 控制器运行行为树。通常,这是一个简单的任务,在蓝图(其中可以直接引用特定的行为树)中实现。即使我们有复杂的C++ AI 控制器,我们也可以在蓝图扩展控制器并从蓝图运行行为树。在任何情况下,如果硬引用不起作用(例如,您正在使用 C++或因为您想要有更多的灵活性),那么您可以将行为树存储在需要运行该特定行为树角色/单位中,并在AI 控制器拥有单位时检索它。

让我们探索如何在蓝图(我们将在一个变量中引用行为树,我们可以决定其默认值)和 C++(我们将把行为树存储在角色中)中实现这一点。

蓝图中的 AI 控制器

我们可以通过单击添加新 | 蓝图类 | AI 控制器来创建蓝图 AI 控制器。您必须单击所有类并搜索AI 控制器来访问它。您可以在以下截图中看到一个示例:

图片

目前,我们将我们的AI 控制器命名为BP_MyFirstAIController。双击它以打开蓝图编辑器

首先,我们需要创建一个变量,以便我们可以存储我们的行为树。尽管保留对行为树的引用不是必需的,但这是一个好的实践。要创建变量,我们需要在我的蓝图面板中按下+ 变量 按钮,位于变量标签旁边,如图所示(请注意,您的光标需要位于变量标签上,按钮才会显示):

图片

然后,作为一个变量类型,你需要选择行为树并给它一个名称,例如BehaviorTreeReference。这就是你的变量应该看起来像的样子:

图片

然后,在详细面板中,我们将设置默认值(记住,为了设置默认值,蓝图需要编译):

图片

然后,我们需要重写On Possess函数,如下面的截图所示:

图片

最后,在AI 控制器的事件拥有中,我们需要开始运行/执行行为树。我们可以通过使用以下简单的节点,命名为运行行为树来实现这一点:

图片

结果,你的 AI 控制器将能够执行存储在BehaviorTreeReference中的行为树

C++中的 AI 控制器

如果你已经决定在 C++中创建这个简单的 AI 控制器,让我们开始吧。我假设你的 Unreal 编辑器已经设置为 C++工作(例如,你已经安装了 Visual Studio,调试符号等),并且你对 C++在 Unreal 中的基本工作原理有基本的了解。以下是一个参考链接,以便你可以开始:docs.unrealengine.com/en-us/Programming/QuickStart以及一个命名规范的链接,以便你理解为什么一些类在代码中带有字母前缀:docs.unrealengine.com/en-us/Programming/Development/CodingStandard

在你开始之前,请记住,为了在 C++中工作 AI,你需要在你的.cs文件中添加公共依赖项(在这个例子中,是UnrealAIBook.cs),并将GameplayTasksAIModule作为公共依赖项添加,如下面的代码所示:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", **"GameplayTasks", "AIModule"** });

这将确保你的代码可以无问题编译。

让我们创建一个新的 C++类,如下面的截图所示:

图片

这个类需要从AIController类继承。你可能需要检查右上角的显示所有类复选框,然后使用搜索栏,如下面的截图所示:

图片

点击下一步并命名类为MyFirstAIController。此外,我建议你保持我们的项目整洁。因此,点击选择文件夹按钮。Unreal 会提示你打开系统文件夹资源管理器。在这里,创建一个名为Chapter2的文件夹,并在其中创建一个名为AI的子文件夹。选择这个文件夹作为你将要存储我们即将创建的代码的地方。在你点击创建之前,对话框应该看起来像这样:

现在,点击创建并等待你的编辑器加载。你可能看到如下内容:

我们代码的结构将与蓝图版本略有不同。实际上,我们不能直接从 AI 控制器类(主要因为直接引用它会很困难)分配一个行为树;相反,我们需要从角色中获取它。正如我之前提到的,当你使用蓝图时,这也是一个好的方法,但既然我们选择了 C++项目,我们应该看看一些代码。在 Visual Studio 中,打开UnrealAIBookCharacter.h文件,并在公共变量下方添加以下代码行:

    //** Behavior Tree for an AI Controller (Added in Chapter 2)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, category=AI)
    UBehaviorTree* BehaviorTree;

对于那些仍然不熟悉的人来说,这里有一段更大的代码块,以便你可以理解如何在类中放置前面的代码:

public:
     AUnrealAIBookCharacter();

    /** Base turn rate, in deg/sec. Other scaling may affect final turn rate. */
     UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=Camera)
     float BaseTurnRate;

    /** Base look up/down rate, in deg/sec. Other scaling may affect final rate. */
     UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=Camera)
     float BaseLookUpRate;

 *//** Behavior Tree for an AI Controller (Added in Chapter 2)*
 *UPROPERTY(EditAnywhere, BlueprintReadWrite, category=AI)*
 *UBehaviorTree* BehaviorTree;*

此外,为了编译前面的代码,我们还需要在类的顶部包含以下语句,就在.generated上方:

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "BehaviorTree/BehaviorTree.h" #include "UnrealAIBookCharacter.generated.h"

关闭角色类,因为我们已经完成了它。因此,每次我们在世界中放置该角色的实例时,我们都能在详细信息面板中指定一个行为树,如下面的截图所示:

让我们打开我们新创建的 AI 控制器的头文件(.h)。特别是,我们需要覆盖 AI 控制器类的一个函数。我们要覆盖的函数叫做Possess(),它允许我们在 AI 控制器拥有一个新的 Pawn(即它控制的角色,它是一个 Pawn)时立即运行一些代码。在受保护的可见性中添加以下加粗代码:

UCLASS()
class UNREALAIBOOK_API AMyFirstAIController : public AAIController
{
     GENERATED_BODY()

protected:

 //** override the OnPossess function to run the behavior tree.
 void OnPossess(APawn* InPawn) override;

};

接下来,打开实现文件(.cpp)。再次使用行为树,我们必须包含行为树UnrealAIBookCharacter类:

#include "MyFirstAIController.h"
#include "UnrealAIBookCharacter.h" #include "BehaviorTree/BehaviorTree.h"

接下来,我们需要为Possess()函数分配一个功能。我们需要检查Pawn是否实际上是UnrealAIBookCharacter,如果是,我们就检索行为树并运行它。当然,这被一个if语句包围,以避免我们的指针是nullptr

void AMyFirstAIController::OnPossess(APawn* InPawn)
{
  Super::OnPossess(InPawn);
  AUnrealAIBookCharacter* Character = Cast<AUnrealAIBookCharacter>(InPawn);
  if (Character != nullptr)
  {
    UBehaviorTree* BehaviorTree = Character->BehaviorTree;
    if (BehaviorTree != nullptr) {
      RunBehaviorTree(BehaviorTree);
    }
  }
}

如果由于任何原因您无法使代码运行,您可以使用蓝图控制器来启动行为树,或者直接继承 C++控制器,并确保所有其他代码都能运行,然后在蓝图中的RunBehaviorTree()函数中进行调用。

一旦我们编译了我们的项目,我们就能使用这个控制器。从层级中选择我们的 AI 角色(如果您没有,您可以创建一个),这次,在详细信息面板中,我们可以设置我们的 C++控制器,如下所示:

此外,别忘了在详细信息面板中将行为树分配好,我们总是这样做:

因此,一旦游戏开始,敌人将开始执行行为树。目前,树是空的,但这给了我们所需的架构,以便我们可以开始使用行为树。在接下来的章节中,我们将更详细地探讨行为树,特别是在第八章、第九章和第十章,我们将探讨设计和构建行为树的更实际的方法。

摘要

在本章中,我们介绍了什么是行为树以及它们包含的一些内容,包括任务装饰器服务。接下来,我们学习了黑板以及如何将其与行为树集成。然后,我们创建了一个行为树并学习了如何从AI 控制器(在蓝图和 C++中)启动它。通过这样做,我们建立了一个坚实的基础,为我们提供了关键知识,以便我们可以处理这本书的其他部分。

因此,在这本书中,我们将遇到更多的行为树,您将有机会掌握它们。但在那之前,我们首先需要了解一些特定的主题。一旦我们有了导航和感知(包括 EQS)的坚实基础,我们就可以迭代行为树来理解复合节点的作用,以及装饰器任务。此外,我们还将能够创建自己的。第八章、第九章和第十章将指导您从头开始创建行为树的过程,从设计阶段到实现阶段。

但在那之前,让我们继续到下一章,我们将讨论导航路径查找

第三章:导航

路径查找背后的问题是与克诺索斯迷宫一样古老:如何使用最短路径从点 A 到点 B,同时避开所有中间的障碍物?

已经开发了许多算法来解决路径查找问题,包括与 A*算法相关的问题,该算法最早在 20 世纪 60 年代计算机科学中引入(第二部分)。

路径查找例程是许多视频游戏的典型组件,非玩家角色(NPC)的任务是在游戏地图上找到最优路径,这些路径可以不断变化。例如,通道、门或门在游戏过程中可以改变其状态。

在路径查找方面存在许多问题,不幸的是,我们并没有一个通用的解决方案。这是因为每个问题都会有其特定的解决方案,这取决于问题的类型。不仅如此,它还取决于你正在开发的游戏类型。例如,AI 的最终目的地是一个静态建筑(静止的),还是他们需要跳上漂浮的筏子(动态的)?你还需要考虑地形——它是平坦的还是多岩石的,等等?为了增加额外的复杂性,我们还需要考虑是否存在障碍物,以及这些物体是静态的(如消防栓)还是可以移动的(例如箱子)。然后,我们需要考虑实际的路径本身。例如,沿着道路旅行可能更容易,但穿越屋顶会更快地到达目的地。遵循同样的思路,AI 可能甚至没有最终目的地,从某种意义上说,他们不需要去某个特定的地方。例如,他们可能只是像村庄里的人一样四处闲逛。然而,我仅仅指出了与路径查找相关的一些问题和考虑因素。随着你经历使用路径查找的不同情况,你可能会遇到其他问题。请记住要有耐心,并考虑我提到的所有变量以及其他特定于你情况的因素。

幸运的是,虚幻引擎已经集成了可以用于最常见情况的导航系统。因此,我们不需要从头开始重新实现一切。本章的主要目标是确保你了解如何使用它,并确保你对如何扩展它有一些想法。

在本章中,我们将涵盖以下主题:

  • 导航系统的期望

  • 虚幻导航系统及其工作原理

  • 如何生成导航网格以及其可用设置

  • 如何修改导航网格,以下方法:

    • 导航区域,以改变与导航网格一部分相关的权重

    • 导航链接,以连接原本分开的导航网格的两部分

    • 导航过滤器,在执行对导航系统的特定查询时对导航网格进行轻微的更改

让我们深入探讨吧!

对导航系统有什么期望

首先,在我们探索虚幻导航系统之前,定义一下我们对一个通用的导航系统的期望是有用的。以下是从导航系统中所需的内容:

  • 需要确定在地图上的两个通用点之间是否存在一条路径(可以由执行查询的代理穿越)

  • 如果存在这样的路径,则返回对代理最方便的路径(通常是最近的路径)

然而,在搜索最佳路径时,有许多方面需要考虑。一个好的导航系统不仅应该考虑这些方面,而且应该在相对较短的时间内执行查询。以下是一些这些方面的例子:

  • 执行查询的人工智能代理能否通过地图上的特定部分?例如,可能有一个湖,AI 角色可能不知道如何游泳。同样,代理能否蹲下并进入通风隧道?

  • 人工智能代理可能想要避免(或偏好)某些路径,这些路径不一定是最近的。例如,如果一座建筑着火了,代理应该尽量避免这种情况,否则可能会被烧伤。作为另一个例子,假设有两条路径:一条被敌人的火力覆盖,但路程较长,而另一条路程较短但暴露在敌人的火力下;AI 应该选择哪一条?虽然这可能是决策过程的一部分,但在路径查找的层面上可以实施一些启发式方法,并且导航系统应该支持它们。

  • 地图可能是动态的,这意味着障碍物、物体、道路、悬崖等在游戏过程中会发生变化。导航系统能否在实时处理这些变化的同时,并纠正生成的路径?

现在,是时候看看虚幻是如何实现所有这些功能的了。

虚幻导航系统

虚幻导航系统基于一个导航网格(简称Nav Mesh)。它包括将可导航空间划分为区域——在这种情况下,多边形——这些区域被细分为三角形以提高效率。然后,为了到达某个地方,每个三角形被视为图中的一个节点,如果两个三角形相邻,则它们的相应节点相连。在这个图上,你可以执行路径查找算法,如带有欧几里得距离启发式的 A算法,甚至更复杂的算法(例如 A的变体或考虑不同成本的系统)。这将在这几个三角形之间产生一条路径,AI 角色可以行走。

在现实中,这个过程要复杂一些,因为将所有三角形视为一个巨大图的网络节点会产生一个好的结果,但这是低效的,尤其是当我们能够访问存储在多边形中的信息以及这些多边形是如何连接的时候。此外,你可能还需要关于特定三角形的一些额外信息,这些三角形可能具有不同的成本、穿越它们所需的不同能力等。然而,除非你需要改变导航系统的底层结构,否则你不需要在这个细节级别上进行工作/操作。能够理解所有三角形以某种方式形成一个图,路径查找算法可以在其中运行,就足以掌握这个工具本身。

要能够使用导航系统,让我们了解设置导航系统的主过程。在这个阶段,我们不再担心系统是如何构建的,而是如何使用其所有功能。系统会完成剩下的工作。同样,我们需要向导航系统提供有关地图的信息(例如,指定特殊区域)。通常,这是你的团队中的 AI 程序员负责这项工作,但如果你的团队规模较小,关卡设计师可能会负责这项任务。尽管没有特定的流程,而是一个迭代过程,但让我们探索你可以用来在 Unreal 中定义导航网格的不同步骤——或者如果你更喜欢,工具。我们将在本章中详细检查它们。

  • 导航网格的生成:这是第一步。在你能够使用以下工具之前,生成一个导航网格非常重要。这一步骤包括定义如何生成多边形、三角形、导航网格的精度,甚至确定哪些类型的代理将穿越这个特定的导航网格

  • 导航网格修改器导航网格的各个部分并不都是相同的,这是一个工具,用于指定导航网格的哪些部分应该有不同的行为。实际上,正如我们之前所看到的,可能有一个含有毒气体的区域,代理会希望避开这部分,除非他们真的不得不穿越它。导航网格修改器允许你指定包含气体的区域是特殊的。然而,区域内的行为类型(例如,这条路径不应该穿越,或者只有具有游泳能力的代理才能穿越)是在导航区域内指定的。

  • 导航区域:这允许你指定特定类型的区域应该如何行为,是否应该避开等。在执行导航过滤以确定代理可以穿越哪些区域时,这些信息是关键的。

  • 导航链接:这些可以连接导航网格的两个不同部分。假设你有一个平台边缘。默认情况下,AI 代理会找到另一条路。如果你考虑的是第三人称地图模板,需要从平台上下来的代理会绕过该区域使用楼梯,而不是直接从平台上掉落/跳跃。一个导航链接允许你连接平台上的导航网格部分与下面的部分。结果,代理将能够从平台上掉落。然而,请注意,导航链接可以连接导航网格的两个通用部分,从而允许路径查找通过跳跃、传送等方式找到路径。

  • 导航过滤:我们并不一定希望每次都以相同的方式找到路径。导航过滤允许我们定义针对特定实例(在路径查找被调用以寻找路径的特定时间)如何执行路径查找的特定规则。

让我们逐一分析这些点,并更详细地讨论它们。

生成导航网格

在虚幻引擎中生成简单的导航网格相当直接。让我们看看我们如何能完成它。从模式面板,在体积选项卡中,你可以找到导航网格边界体积,如下截图所示:

图片

将其拖入世界。你会注意到与地图相比,体积相当小。该体积内的所有内容都将被考虑以生成导航网格。当然,导航网格有许多参数,但为了简单起见,我们现在保持简单。

如果你按下键盘上的P按钮,你将能够在视口中看到导航网格,如下截图所示:

图片

如你所见,它限制在导航网格边界体积所包含的区域。让我们调整导航网格边界体积以适应我们拥有的所有关卡。你的关卡应该看起来像这样:

图片

你注意到当你调整体积时,导航网格会自动更新吗?这是因为,在虚幻引擎中,每次影响导航网格的任何东西移动时,都会生成导航网格

在更新过程中,受影响的导航网格部分(即更新的部分)应变为红色,如下截图所示:

图片

这就是生成导航网格有多简单。然而,为了能够掌握这个工具,我们需要了解更多关于如何细化导航网格以及它是如何被 AI 使用的。

为导航网格设置参数

如果你点击导航网格边界体积,你会注意到没有生成导航网格的选项。事实上,一些参数是在项目级别,而另一些是在地图级别。

让我们导航到世界轮廓图,在那里你会发现场景中已经放置了一个RecastNavMesh-Default演员,如下面的截图所示:

截图

实际上,当你拖动导航网格边界体积时,如果地图中没有RecastNavMesh-Default,则会创建一个。如果我们点击它,我们将在详细信息面板中能够更改其所有属性。

如你所见,有很多默认值。这些可以在项目设置(在导航网格选项卡下)中更改。让我们逐个分析每个部分,并尝试掌握它们的主要概念。

显示设置

正如名称所示,这些是与如何详细可视化我们生成的导航网格相关的设置。特别是,我们将能够看到生成的多边形、三角形以及多边形是如何连接的。我们将在第十二章,AI 调试方法 - 导航、EQS 和性能分析中更详细地介绍这些内容,届时我们将讨论调试工具:

截图

生成设置

这些设置涉及导航网格的生成。通常,默认值已经足够好,可以开始使用,因此只有在你了解自己在做什么的情况下才应该更改这些值。以下截图显示了这些设置:

截图

了解这些设置的最好方法是调整它们的参数,首先在一个示例地图中,然后在自己的地图中进行。之后,你需要检查这样做的结果(特别是第十二章中介绍的视觉调试工具,AI 调试方法 - 导航、EQS 和性能分析)。为了帮助你入门,让我们看看主要的设置:

  • 瓦片大小 UU:此参数定义了生成的多边形的精细程度。较低的值意味着更精确的导航网格,具有更多的多边形,但生成时间会更长(并且可能使用更多的内存)。你可以通过在显示设置中打开绘制三角形边缘来查看此参数的效果,如前一个截图所示。

  • 单元格高度:这决定了生成的单元格从地板的高度(这可能会导致连接不同高度的区域,所以请小心)。

  • 代理设置半径高度最大高度最大坡度最大步高):这些设置针对您的代理,应适当指定。特别是,这些是代理穿越此Nav Mesh所需的最小值。因此,Nav Mesh将无法导航具有比这些值更小值的代理,因为Nav Mesh只为满足这些要求的代理生成。这些设置有助于为您的代理生成适当的Nav Mesh,而不会在代理永远无法导航的区域浪费资源。

  • 最小区域面积:这消除了Nav Mesh 生成中的一些过于微不足道的瑕疵。

许多剩余的设置都是关于优化的,它们可能会让人感到不知所措,尤其是对于 AI 编程的新手来说。因此,我决定不将这些细节包含在本书中。然而,一旦您对使用导航系统有信心,您就可以检查这些设置的提示,并尝试它们,以便了解它们的作用。

项目设置

值得注意的是,即使我们不详细讨论,相同的导航设置也可以从项目设置中更改;有一个专门的选项卡,如下图所示:

图片

有趣的是,最后一个选项卡是关于代理的。在这里,您可以创建一个支持代理数组,以便不同的代理可以以不同的方式在Nav Mesh中导航。例如,鼠标可能有一个与巨魔非常不同的导航网格。事实上,鼠标还可以进入小洞,而巨魔则不能。在这里,您将能够指定您拥有的所有不同类型的代理:

图片

您不能直接指定角色将跟随哪种类型的代理,但基于角色移动组件(或一般意义上的移动组件),会将一种代理分配给角色/AI 代理

角色移动组件上的设置

如前节所述,代理的能力、形状等对其在Nav Mesh中的导航有很大影响。您可以在角色移动组件中找到所有这些设置。

然而,这个组件超出了本书的范围,我们不会看到它。

修改导航网格

到目前为止,我们已经看到了如何生成导航网格。然而,我们希望对其进行修改,使其更好地满足我们的需求。正如我们之前提到的,可能会有一些区域穿越成本较高,或者Nav Mesh中两个点之间的连接似乎被隔开(例如,由悬崖隔开)。

因此,本节探讨了 Unreal 中用于修改Nav Mesh的不同工具,以便它可以适应关卡。

Nav Modifier Volume

好的——现在是时候看看我们如何开始修改 Nav Mesh 了。例如,可能有一些我们不希望 AI 可以穿越的 Nav Mesh 部分,或者我们希望另一部分具有不同的属性。我们可以通过使用 Nav Modifier Volume 来实现这一点。

您可以通过转到 Mode 面板,在 Volumes 选项卡下,然后转到 Nav Mesh Bounds Volume 来找到此设置:

图片

一旦这个体积被放置到地图中,默认值是移除体积内的 Nav Mesh 部分,如下面的截图所示:

图片

当你有不想让 AI 进入的区域,或者修复导航网格的瑕疵时,这很有用。尽管 Nav Modifier Volume 指定了地图的一部分,但其行为是在 Nav Mesh Areas 中指定的。这意味着,如果我们查看 Nav Mesh Modifier Volume 的设置,我们只能找到一个与 Navigation 相关的设置,名为 Area Class

图片

因此,本卷只能指定应用了特定 区域类别 的地图的一部分。默认情况下,区域类别NavArea_Null,它将地图中与该体积重叠的部分的 Nav Mesh “移除”。我们将在下一节中探讨 Nav Mesh Areas 的工作原理。

Nav Mesh Areas

在上一节中,我们讨论了地图的可导航区域并非所有部分都同等重要。如果有一个被认为是危险区域的区域,AI 应该避开它。虚幻引擎内置的导航系统能够通过使用成本来处理这些不同的区域。这意味着 AI 将通过计算路径上的所有成本来评估要采取的路径,并选择成本最低的那条路径。

此外,还值得指出的是,存在两种类型的成本。对于每个区域,都有一个进入(或离开)区域的基本成本和穿越区域的成本。让我们通过几个例子来澄清这两种成本之间的区别。

想象一下有一个森林,但在森林的每个入口处,AI 都需要向森林中的土著居民支付通行费。然而,一旦进入森林,AI 可以自由移动,就像他们在外面一样。在这种情况下,进入森林有成本,但一旦进入,就没有成本需要支付。因此,当 AI 需要评估是否穿越森林时,这取决于是否有其他路线以及他们这样做需要多长时间。

现在,想象一下有一个区域充满了毒气。在这个第二种情况下,进入该区域的成本可能是零,但穿越该区域的成本很高。事实上,AI 在该区域停留的时间越长,它的健康值损失就越多。是否值得进入不仅取决于是否有替代路线以及穿越替代路线需要多长时间(就像在先前的例子中那样),还取决于一旦进入,AI 需要穿越该区域多长时间。

在 Unreal 中,成本是在类中指定的。如果你点击一个 Nav Modifier Volume,你会注意到你需要指定一个 Area Class,如下面的截图所示:

图片

如你所猜,默认值是 NavArea_Null,进入该区域的成本是无限的,导致 AI 从不进入该区域。导航系统足够智能,甚至不会生成该区域,将其视为不可导航区域。

然而,你可以更改 Area 类。默认情况下,你将能够访问以下 Area Classes

  • NavArea_Default:这是默认生成的区域。如果你想在同一个位置有多个这些修饰符,那么它很有用。

  • NavArea_LowHeight:这表明该区域不适合所有代理,因为高度降低了(例如,在通风隧道的情况下,并非所有代理都能适应/蹲下)。

  • NavArea_Null:这使得该区域对所有代理都不可导航。

  • NavArea_Obstacle:这会给区域分配更高的成本,因此代理会想要避开它:

图片

你会注意到,如果你创建一个新的蓝图,或者甚至当你在 Visual Studio 中打开源代码时,都会有一个 NavArea_Meta 以及它的一个子项,NavArea_MetaSwitchingActor。然而,如果你查看它们的代码,它们主要有一些过时的代码。因此,我们在这本书中不会使用它们。

然而,你可以通过扩展 NavArea 类 来扩展不同区域列表(并且可能添加更多功能)。让我们看看我们如何在蓝图和 C++ 中做到这一点。当然,就像我们在上一章中做的那样,我们将创建一个名为 Chapter3/Navigation 的新文件夹,我们将把所有的代码放在这个文件夹中。

在蓝图中创建 NavArea 类

在蓝图中创建一个新的 NavArea 类相当简单;你只需要创建一个新的蓝图,它继承自 NavArea 类,如下面的截图所示:

图片

按照惯例,类的名称应该以 "*NavArea_" 开头。在这里我们将将其重命名为 NavArea_BPJungle(我添加了 BP 来表示我们是用蓝图创建的,因为我们同时在蓝图和 C++ 中重复执行相同的任务)。这是它在 内容浏览器 中的样子:

然后,如果你打开蓝图,你将能够为该区域分配自定义成本。你还可以为你的区域指定一个特定的颜色,以便在构建导航网格时易于识别。这是默认情况下详细信息面板的外观:

现在,我们可以根据我们的需求进行自定义。例如,我们可能想要为进入丛林设置一个成本,并且穿越它时设置一个略高的成本。我们将使用明亮的绿色作为颜色,如下面的截图所示:

编译并保存后,我们可以将这个新创建的区域分配给Nav Modifier Volume,如下面的截图所示:

这是我们级别中完成后的类的外观(如果导航网格可见):

在 C++中创建 NavArea 类

在 C++中创建一个NavArea类同样简单。首先,你需要创建一个新的 C++类,该类从NavArea类继承,如下面的截图所示:

按照惯例,名称应该以"NavArea_"开头。因此,你可以将其重命名为NavArea_Desert(只是为了改变 AI 可能遇到的哪种地形,因为我们之前创建了一个丛林),并将其放置在"Chapter3/Navigation":

一旦创建了类,你只需要在构造函数中分配参数。为了方便起见,以下是类定义,其中我们声明了一个简单的构造函数:

#include "CoreMinimal.h"
#include "NavAreas/NavArea.h"
#include "NavArea_Desert.generated.h"

/**
 * 
 */
UCLASS()
class UNREALAIBOOK_API UNavArea_Desert : public UNavArea
{
  GENERATED_BODY()

  UNavArea_Desert();

};

然后,在构造函数的实现中,我们可以分配不同的参数。例如,我们可以为进入设置一个高成本,为穿越设置一个更高的成本(相对于默认丛林)。此外,我们可以将颜色设置为黄色,以便我们记住这是一个沙漠区域:

#include "NavArea_Desert.h"

UNavArea_Desert::UNavArea_Desert()
{
  DefaultCost = 1.5f;
  FixedAreaEnteringCost = 3.f;
  DrawColor = FColor::Yellow;
}

你可以随时调整这些值以查看哪个最适合你。例如,你可以创建一个进入成本非常高但穿越成本很低的区域。结果,如果只穿越一小段时间,该区域应该被避免,但如果代理穿越它的时间较长,它可能比较短路线更方便。

一旦创建了类,你可以将其设置为Nav Modifier Volume的一部分,如下面的截图所示:

因此,你将能够在导航网格(在这种情况下,带有黄色)中看到你的自定义区域:*

导航链接代理

默认情况下,如果有一个悬崖,AI 不会从悬崖上掉下去,即使这是它们到达目的地的最短路径。实际上,悬崖上的“导航网格”并没有(直接)与底部的“导航网格”连接。然而,“虚幻导航系统”提供了一种通过所谓的“导航链接代理”连接“导航网格”中任意两个三角形的方法。

尽管区域是连接的,路径查找器也会找到正确的道路,但 AI 不能违反游戏规则,无论是物理规则还是游戏机制。这意味着如果 AI 无法跳跃或穿越魔法墙,角色会卡住,因为路径查找器返回了一条路径,但角色无法执行它。

让我们更详细地探索这个工具。

创建导航链接代理

要通过链接连接两个区域,我们需要进入“模式”面板,在“所有类”选项卡中并选择“导航链接代理”,如图所示:

截图

或者,你可以在“模式”面板中搜索它以更快地找到它:

截图

一旦链接被放置在层级中,你将看到一个“箭头/链接”,并且你可以修改链接的起始点和终点。它们被称为“”和“”,设置它们位置的最简单方法是拖动(并放置)它们在“视口”中。结果,你将能够连接“导航网格”的两个不同部分。正如我们在以下截图中可以看到的,如果“导航网格”是可见的(通过按“P”键启用),你将看到一个连接“”和“”节点的箭头。这个箭头指向两个方向。这将导致链接是双向的

截图

你可能会注意到有两个箭头,一个带有较深的绿色阴影。此外,这个第二个“箭头/弧/链接”可能并不完全在你放置的“”端点处,而是附着在“导航网格”上。你可以在以下截图中更清楚地看到这个第二个箭头:

截图

这实际上是“导航网格”是如何通过“链接”的“投影设置”连接起来的。我们将在下一节中探讨这个设置。

如果你想让链接只向一个方向走,我们可以在“详情面板”中更改这个设置。然而,要探索这些设置,我们首先需要理解存在两种不同的“链接”类型:“简单”和“智能”。

简单链接和智能链接

当我们创建一个“导航链接代理”时,它附带一系列“简单链接”。这意味着我们可以使用单个“导航链接代理”将“导航网格”的不同部分连接在一起。然而,“导航链接代理”还附带一个默认禁用的单个“智能链接”。

让我们了解简单链接智能链接之间的相似之处和不同之处。

简单链接和智能链接

简单链接和智能链接的行为方式相似,即在意义上将导航网格的两个部分连接起来。此外,这两种类型的链接都可以有方向从左到右从右到左,或双向)和导航区域(链接所在的导航区域类型;例如,您可能希望在通过此链接时使用自定义成本)。

简单链接

简单链接存在于导航代理链接中的点链接数组中,这意味着在单个导航代理链接中可以存在多个简单链接。要创建另一个简单链接,您可以从详细信息面板中向简单节点数组添加一个额外的元素,如下所示:

图片

一旦我们有了更多的简单链接,我们可以设置起始结束位置,就像我们为第一个链接所做的那样(通过选择它们并在视口内移动它们,就像其他任何代理一样)。以下截图显示了我在同一导航代理链接旁边放置的两个简单链接的位置:

图片

每次我们创建一个导航链接代理时,它都会在数组中包含一个简单链接

对于我们在点链接数组中的每个简单链接,我们可以通过展开项目来访问其设置。以下截图显示了第一个简单链接的设置:

图片

让我们了解这些不同的设置:

  • :链接端的位置,分别。

  • 左投影高度右投影高度:如果此数字大于零,则链接将分别投影到链接端导航几何形状上(使用最大长度由此数字指定的跟踪)。您可以在以下截图中看到此投影链接:

图片

  • 方向:这指定了链接工作的方向。此外,视口中的箭头将相应更新。此选项的可能如下:

    • 双向:链接是双向的(请记住,AI 需要能够以两个方向穿越链接;例如,如果我们正在越过悬崖,代理需要能够从它上掉落(链接的一个方向)和跳跃(链接的另一个方向)。

    • 从左到右:链接只能从左端向右端穿越(代理仍然需要具备在该链接方向行进的能力)。

    • 从右到左:链接只能从右端向左端穿越(代理仍然需要具备在该链接方向行进的能力)。

  • 吸附半径高度半径:您可能已经注意到连接每个链接末端的圆柱体。这两个设置控制该圆柱体的半径和高度。查看吸附到最便宜的区域以获取有关该圆柱体使用的更多信息。以下截图显示第一个链接有一个更大的圆柱体(更大的半径和更高的高度):

图片

  • 描述:这只是一个字符串,您可以在其中插入方便的描述;它对导航链接没有影响。

  • 吸附到最便宜的区域:如果启用,它将尝试将链接端连接到由吸附半径高度半径指定的圆柱体内的最便宜的三角形区域。例如,如果圆柱体同时与默认导航区域BPJungle导航区域(我们之前创建的)相交,链接将直接连接到默认导航区域,而不是丛林。

  • 区域类:链接可能具有穿越成本,或属于特定的导航区域。此参数允许您定义链接穿越时是哪种类型的导航区域

这就结束了所有关于简单链接的可能性。然而,这是一个非常强大的工具,让您能够塑造导航网格并实现惊人的 AI 行为。现在,让我们深入了解智能链接

智能链接

智能链接可以通过使用“智能链接相关”布尔变量在运行时启用和禁用。您还可以通知周围的演员这一变化。默认情况下,它是不相关的(它没有被使用,即链接不可用),并且每个导航代理链接只有一个智能链接

请注意,不要混淆:智能链接可以处于两种状态:启用和禁用。然而,如果链接实际上是“存在/存在”(对于导航网格),这又是另一个属性(智能链接相关),换句话说,这意味着链接对于导航系统来说是“活动”的(但它仍然可以处于启用或禁用状态)。

不幸的是(至少对于当前版本的引擎),这些在编辑器中是不可见的,这意味着需要手动设置起始结束位置。

然而,让我们来看看智能链接的设置:

图片

  • 启用区域类:这是链接启用时假设的导航区域。默认为NavArea_Default

  • 禁用区域类:这是链接禁用时假设的导航区域。这意味着当链接禁用时,如果分配了可穿越的区域(例如,当链接禁用时,我们可能希望有非常高的成本来穿越,但我们仍然希望它能够穿越。当然,默认为NavArea_Default,这意味着它不可穿越。

  • 链接相对起始:这表示链接的起始点,相对于其导航链接代理的位置。

  • 链接相对结束:这表示链接的结束点,相对于其导航链接代理的位置。

  • 链接方向:这指定了链接工作的方向。可能的选项如下:

    • 双向:链接是双向的(记住 AI 需要能够双向穿越链接;例如,在悬崖上,代理需要能够从上面掉落(链接的一个方向)和跳跃(链接的另一个方向)。

    • 从左到右:链接只能从左端穿越到右端(代理仍然需要在该链接方向上移动的能力)。

    • 从右到左:链接只能从右端穿越到左端(代理仍然需要在该链接方向上移动的能力)。

虽然此参数的选项将链接的端点标记为,但它们指的是链接的起始点和结束点。或者(这可能更好,因为链接可以是双向的),链接相对起始链接相对结束指的是

  • 链接启用:这是一个布尔变量,用于确定智能链接是否启用。此值可以在运行时更改,并且链接可以"通知"对这种信息感兴趣的周围代理/演员(见后文了解更多信息)。默认值是 true。

  • 智能链接相关:这是一个布尔变量,用于确定智能链接是否实际上是"活动状态",即它是否相关,或者我们应该忽略它。默认值是 false。

这些是关于智能链接的主要设置。

值得注意的是,智能链接实际上可以做的不仅仅是连接导航网格。它们有一系列处理正在穿越链接的代理的功能。例如,通过打开NavLinkProxy.h文件,我们可以找到以下函数:

  /** called when agent reaches smart link during path following, use ResumePathFollowing() to give control back */
  UFUNCTION(BlueprintImplementableEvent)
  void ReceiveSmartLinkReached(AActor* Agent, const FVector& Destination);

  /** resume normal path following */
  UFUNCTION(BlueprintCallable, Category="AI|Navigation")
  void ResumePathFollowing(AActor* Agent);

  /** check if smart link is enabled */
  UFUNCTION(BlueprintCallable, Category="AI|Navigation")
  bool IsSmartLinkEnabled() const;

  /** change state of smart link */
  UFUNCTION(BlueprintCallable, Category="AI|Navigation")
  void SetSmartLinkEnabled(bool bEnabled);

  /** check if any agent is moving through smart link right now */
  UFUNCTION(BlueprintCallable, Category="AI|Navigation")
  bool HasMovingAgents() const;

不幸的是,这些功能超出了本书的范围,但我邀请您阅读代码以了解更多关于它们的信息。

之前我们提到,智能链接可以在运行时向附近的代理/演员广播有关其状态变化的信息。您可以通过以下广播设置更改智能链接广播此信息的方式,这些设置位于智能链接下方:

这些设置相当直观,但让我们快速浏览一下:

  • 启用时通知:如果为真,链接将在启用时通知代理/演员。

  • 禁用时通知:如果为真,链接将在禁用时通知代理/演员。

  • 广播半径:这指定了广播应该延伸多远。所有位于此半径之外的代理都不会收到关于链接变化的通知。

  • 广播间隔:这指定了链接应该在多长时间后重复广播。如果值为零,则广播只重复一次。

  • 广播频道:这是用于广播变化的跟踪频道。

这就结束了我们对 智能链接 的讨论。

其他 Nav Link Proxy 设置

最后,值得一提的是,当生成 Nav Mesh 时,Nav Link Proxy 可以创建一个 障碍盒。你可以在 Nav Link Proxy详细信息面板 中找到这些设置,如下面的截图所示:

这些设置允许你决定是否激活/使用 障碍盒,其 尺寸/范围 和偏移量,以及 导航区域 的类型。

扩展 Nav Link Proxy

如果你想知道是否可以扩展 链接 或在更复杂的演员中包含它们,答案是“当然可以!但你只能用 C++ 扩展它们”。

由于这本书不能涵盖所有内容,我们没有时间详细处理这部分。然而,你可能想要扩展 Nav Link Proxy 的原因之一是更好地控制进入你的链接的角色。例如,你可能想要有一个 跳跃垫 将角色推过链接。这并不复杂,如果你在网上搜索,你会找到很多关于如何使用 导航链接 来实现这一点的教程。

只需记住,要成为一名优秀的 Unreal AI 程序员,你最终需要掌握 Nav Links 的这部分内容,但就目前而言,我们已经涵盖了足够的内容。

导航规避

导航规避是一个非常广泛的话题,Unreal 有一些子系统为我们处理这个问题。因此,我们将把这个话题放在 第六章,人群 中讨论。

导航过滤

我们不希望每次都以相同的方式找到特定的路径。想象一下,我们的 AI 代理使用了一个增强效果,它能够以两倍的速度穿越丛林。在这种情况下,导航系统没有意识到这种变化,这也不是对 Nav Mesh 形状或权重的永久性更改。

导航过滤 允许我们定义在特定时间段内如何执行路径查找的具体规则。你可能已经注意到,每次我们在蓝图或 C++ 中执行导航任务时,都有一个可选参数用于插入一个 导航过滤器。以下是一些具有此可选过滤器参数的蓝图节点(C++ 函数也是如此)的示例:

即使是 行为树 中的 移动到 节点也有 导航过滤器 选项:

当然,一旦您插入了一个过滤器,路径查找将相应地表现。这意味着使用导航过滤器非常简单。然而,我们如何创建导航过滤器?让我们在蓝图和 C++中找出答案。

在蓝图创建导航过滤器

在本章之前,我们在蓝图中创建了一个丛林区域。因此,这似乎是一个很好的例子,我们可以用它来创建一个允许 AI 代理更快地穿越丛林——甚至比穿越导航网格默认区域还要快——的导航过滤器。让我们想象 AI 代理有一些力量或能力,允许它在关卡中的丛林类型区域中更快地移动。

要在蓝图创建一个导航过滤器,我们需要开始创建一个新的蓝图,该蓝图继承自NavigationQueryFilter,如下面的截图所示:

图片

按照惯例,类的名称应该以"NavFilter_"开头。我们将将其重命名为NavFilter_BPFastJungle(我添加了 BP,以便我可以记住我是用蓝图创建的,因为我们正在蓝图和 C++中重复相同的任务)。这是它在内容浏览器中的样子:

图片

一旦我们打开蓝图,我们将在详细信息面板中找到其选项:

图片

如您所见,有一个区域数组和两个用于包括和排除(导航)标志的集合。不幸的是,我们没有涵盖导航标志,因为它们超出了本书的范围,并且在撰写时只能在 C++中分配。然而,区域数组非常有趣。让我们添加一个新的区域,并使用我们的NavArea_BPJungle作为区域类,如下面的截图所示:

图片

现在,我们可以覆盖丛林区域的旅行成本进入成本,如果使用此过滤器,则将使用这些成本代替我们在区域类中指定的成本。请记住勾选选项旁边的复选框以启用编辑。例如,我们可以将旅行成本设置为0.6(因为我们可以快速通过丛林而不会遇到任何问题),并将进入成本设置为

图片

现在,我们一切都准备好了。过滤器已准备好供您在丛林中旅行时使用!

导航区域 更改 旅行成本 并不会使 AI 代理在该区域更快或更慢,它只是使路径查找更倾向于该路径而不是另一条路径。代理在该区域变得更快是实现,被排除在导航系统之外,因此您需要在 AI 角色在丛林中时实现这一点。

如果你同时也跟随了Nav Areas的 C++部分,那么你应该在你的项目中也有沙漠区域。作为一个可选步骤,我们可以向过滤器添加第二个区域。想象一下,通过使用在丛林中移动更快的加成或能力,我们的角色对阳光变得非常敏感,很容易晒伤,这会显著降低他们的健康。因此,如果使用此过滤器,我们可以为沙漠区域设置更高的成本。只需添加另一个区域,并将区域类设置为NavArea_Desert。然后,覆盖成本;例如,一个旅行成本2.5进入成本10

一旦你完成了设置编辑,保存蓝图。从现在起,你将能够在导航系统中使用此过滤器。这标志着如何在蓝图中创建Nav Filter的方法结束。

在 C++中创建导航过滤器

以类似蓝图的方式,我们可以创建一个 C++的Nav Filter。这次,我们可以创建一个稍微降低沙漠区域成本的过滤器。你可以将此过滤器用于某些生活在沙漠中的动物,使其不太容易受到其影响。

首先,我们需要创建一个新的 C++类,它继承自NavigationQueryFilter,如下面的截图所示:

按照惯例,类的名称应该以"NavFilter_"开头。因此,我们将将其重命名为NavFilter_Desert Animal并将其放置在"Chapter3/Navigation":

为了设置其属性,我们需要创建一个默认构造函数。在头文件(.h)中写下以下内容:

#include "CoreMinimal.h"
#include "NavFilters/NavigationQueryFilter.h"
#include "NavFilter_DesertAnimal.generated.h"

/**
 * 
 */
UCLASS()
class UNREALAIBOOK_API UNavFilter_DesertAnimal : public UNavigationQueryFilter
{
  GENERATED_BODY()

 UNavFilter_DesertAnimal();
};

对于实现(.cpp文件),我们需要做更多的工作。首先,我们需要访问我们需要的Nav Area,在这种情况下,是沙漠。让我们添加以下#include语句:

#include "NavArea_Desert.h"

然后,在构造函数中,我们需要创建一个FNavigationFilterArea,这是一个包含过滤特定类所有选项的类。在我们的例子中,我们可以将这个新的过滤器区域存储在一个名为Desert的变量中:

UNavFilter_DesertAnimal::UNavFilter_DesertAnimal() {

 //Create the Navigation Filter Area
 FNavigationFilterArea Desert = FNavigationFilterArea();

 *// [REST OF THE CODE]*
}

接下来,我们需要将Desert变量填充为我们想要覆盖该类的选项,包括我们正在修改的哪个Nav Area

UNavFilter_DesertAnimal::UNavFilter_DesertAnimal() {

 *// [PREVIOUS CODE]*

 //Set its parameters
 Desert.AreaClass = UNavArea_Desert::StaticClass();

 Desert.bOverrideEnteringCost = true;
 Desert.EnteringCostOverride = 0.f;

 Desert.bOverrideTravelCost = true;
 Desert.TravelCostOverride = 0.8f;

 *// [REST OF THE CODE]*
}

最后,我们需要将此过滤器区域添加到Areas数组中:

UNavFilter_DesertAnimal::UNavFilter_DesertAnimal() {

 *// [PREVIOUS CODE]*

 //Add it to the the Array of Areas for the Filter.
 Areas.Add(Desert);
}

为了方便起见,以下是完整的.cpp文件:

#include "NavFilter_DesertAnimal.h"
#include "NavArea_Desert.h"

UNavFilter_DesertAnimal::UNavFilter_DesertAnimal() {

  //Create the Navigation Filter Area
  FNavigationFilterArea Desert = FNavigationFilterArea();

  //Set its parameters
  Desert.AreaClass = UNavArea_Desert::StaticClass();

  Desert.bOverrideEnteringCost = true;
  Desert.EnteringCostOverride = 0.f;

  Desert.bOverrideTravelCost = true;
  Desert.TravelCostOverride = 0.8f;

  //Add it to the the Array of Areas for the Filter.
  Areas.Add(Desert);
}

编译此代码,你将能够在下次需要使用导航系统时使用此过滤器。这标志着我们对导航过滤器的讨论结束。

覆盖导航系统

模式面板中,你可以将一个名为Nav System Config Override的特殊演员拖入级别。

此演员允许你通过使用另一个来覆盖内置的导航系统。当然,你将不得不首先开发它,这将需要大量的努力。

图片

你应该替换默认的导航系统(或者可能与其他系统一起使用)的原因主要是为了克服限制。那么空中单位呢?它们如何进行 3D 路径查找?蜘蛛如何进行表面路径查找?

摘要

在本章中,我们探讨了如何设置导航系统,以便我们的 AI 角色可以在地图上移动。特别是,我们学习了如何使用修改体积导航链接代理导航网格区域来塑造导航网格

因此,我们的 AI 代理可以平滑地穿越地图,高效地找到两点之间的路径,该路径基于他们的能力进行了优化(例如,使用导航过滤器),同时尊重地图上各种类型的"地形"(例如,使用导航区域)。此外,它们可以翻过悬崖或跳过平台(例如,通过使用导航链接代理和一点跳跃的编码)。

在下一章中,我们将学习关于 Unreal 框架中更高级的 AI 功能,即环境查询系统,它允许代理"查询"环境,以便他们可以找到具有特定要求的地点(或演员)。

第四章:环境查询系统

一位优秀的领导者知道哪里是好的,而 EQS 知道得更好!

欢迎来到第四章,环境查询系统。Chapter 4。在本章中,我们将使用虚幻 AI 框架中的一个特定且非常强大的系统。我指的是 环境查询系统EQS)。我们将探索这个系统,并不仅了解其工作原理,还将了解如何在我们的游戏中有效地使用它。

再次强调,EQS 属于 决策制定 的领域,特别是评估哪个位置(或虚幻中的演员)最适合满足某些条件。我们将通过本章详细了解其工作原理,但作为对我们将要涵盖内容的预览,请记住,系统过滤器提供不同的可能性,剩余的则分配一个分数。得分最高的选择将被选中。

尤其是我们将涵盖以下主题:

  • 如何启用 环境查询系统 (EQS)

  • 理解 EQS 的工作原理

  • 了解 GeneratorsTestsContexts

  • 探索 EQS 内置的 GeneratorsTestsContexts

  • 使用自定义的 GeneratorsTestsContexts 来扩展 EQS

那么,让我们深入探讨吧!

启用环境查询系统

EQS 是一个在 Unreal 4.7 中引入的功能,在 4.9 中得到了很大的改进。然而,在版本 4.22 中,EQS 被列为实验性功能,尽管它在许多游戏中得到了成功应用,这表明 EQS 是稳健的。

因此,我们需要从 实验性功能设置 中启用它。从顶部菜单,转到 编辑 | 编辑器首选项…,如下面的截图所示:

请注意,不要与 项目设置 混淆。从顶部菜单,在 视口 之上,您只能访问 项目设置。然而,从整个编辑器的顶部菜单中,您将能够找到 编辑器首选项。前面的截图应有助于您找到正确的菜单(即 编辑 下拉菜单)。

从侧边菜单中,您将能够看到一个名为 Experimental(在 General 类别下)的部分,如下面的截图所示:

如果您滚动浏览设置,您将找到 AI 类别,其中您可以启用 环境查询系统

勾选此选项旁边的框,结果,整个项目中的 环境查询系统 将被激活。现在,您将能够为其创建资产(以及扩展它),并从 行为树 中调用它。

如果你在AI 类别中看不到环境查询系统复选框,那么你很可能正在使用一个较新的引擎版本,其中(终于)EQS不再是实验性的,因此在你的项目中始终是启用的。如果这是你的情况,那么请跳过这一部分,继续下一部分。

理解环境查询系统

当人们第一次面对 EQS 时,可能会感到不知所措,尤其是因为它不清楚系统的不同部分是如何工作的以及为什么。本节的目标是通过让你熟悉 EQS 的底层工作流程来提高你对系统的理解,这将有助于你在创建查询时的实际工作流程。

EQS 的一般机制

想象一下,在某个时刻,我们的 AI 代理正在遭受火力攻击,并且需要评估不同的掩护地点。一个地方可能很远但保护得很好,而另一个地方可能很近但保护得不好。我们应该怎么办?

解决这个问题的方法之一是使用效用函数并在时间上解决方程(我们将在第十四章,超越)中详细讨论)。实际上,这会产生非常好的结果,并且已经在许多游戏中成功实施。然而,Unreal 提供了另一种可能性:EQS。也就是说,使用 EQS 而不是效用函数不是强制性的,但作为 AI 框架的一部分,EQS 使得评估此类决策变得容易,因为它是一个内置的系统。

因此,回到我们那个需要掩护的代理,一个行为树将运行一个 EQS 查询,这将给出代理应该获得掩护的最终位置。现在,环境查询是如何工作的呢?

首先,一个组件(称为生成器,我们稍后将讨论)将根据在测试中指定的某些标准生成一系列位置(或代理,我们将在本章稍后讨论)。例如,我们可以在一个均匀的网格上取不同的位置,这在不知道预先(在评估之前)要寻找哪种位置时非常有用。

然后,有一个过滤过程用于可能的地点(或演员),其中它会消除所有不符合特定标准的。在我们的掩护示例中,任何仍然暴露在直接火力下的地方都应该被丢弃。

剩余的地方将根据其他标准进行评估(系统会为它们分配一个分数)。再次,在我们的掩护示例中,这可能是代理的距离、它们提供的掩护程度,或者地点离敌人的距离。系统通过考虑所有这些因素(当然,其中一些因素可能比其他因素更重要;例如,从火力的保护可能比从敌人位置的距离更重要)来分配分数。

最后,从查询中给出得分最高的位置(或演员)到 行为树,这将决定如何处理它(例如,快速逃离到那个地方以躲避)。

环境查询的组件

基于我们在上一节中描述的机制,让我们深入了解 Unreal 中 EQS 的实际实现方式。

在高层次上,我们有 环境查询上下文生成器测试

环境查询

如其名所示,环境查询 是一种数据结构(类似于 行为树),它包含有关如何执行查询的信息。实际上,它是一个您可以在您的 内容浏览器 中创建和找到的资产。

您可以通过在您的 内容浏览器 上右键单击,然后选择 人工智能 | 环境查询 来创建一个新的 环境查询,如下面的截图所示:

图片

请记住,如果 EQS 未启用,此选项将不会出现。

这是在 内容浏览器 中的样子:

图片

如果我们双击它以打开它,Unreal 将打开一个特定且 专用 的编辑器用于 环境查询。这就是编辑器的样子:

图片

编辑器视图

如您所见,它与 行为树 非常相似,但您只能将一个生成器节点附加到 根节点(只有一个),这将使其也成为叶子节点。因此,整个 "" 将只是 根节点 和一个 生成器。实际上,通过使用类似 行为树 的编辑器,您可以轻松设置一个 环境查询。在(唯一的)生成器 节点上,您可以附加一个或多个 测试节点*——无论是生成器本身还是上下文。以下是一个示例:

图片

编辑器视图

我们将在下一节中了解这意味着什么。

上下文

上下文是特定且方便的类,用于检索信息。您可以通过蓝图或使用 C++ 来创建/扩展一个 上下文

它们被称为上下文的原因是,它们为生成器或测试提供了一个上下文。通过拥有上下文,生成器(或测试)能够从那个点开始执行所有计算。如果您愿意,可以将上下文视为一个特殊(并且非常详细的)变量,能够程序化地传递一组有趣的演员和/或位置。

让我们通过一个例子来看看上下文是什么。在进行测试时,您通常知道查询者(例如,需要掩护的智能体)的位置(在引擎盖下,即使查询者是默认上下文)。然而,我们的测试可能需要我们敌人的位置(例如,检查掩护点是否受到攻击,因为这取决于我们智能体的敌人的位置)。上下文可以提供所有这些信息,并且可以以程序化的方式做到这一点:例如,智能体可能不知道地图上的每个敌人,因此上下文可能只返回智能体当前意识到的敌人,因此它只从那些地方找到掩护。因此,如果有一个隐藏的敌人被选为掩护的地方,那么对我们智能体来说就是不幸的事情!

理解上下文并不容易,所以请坚持这一章,也许在您对生成器和测试以及如何在我们的项目中构建EQS有更好的了解之后,再重新阅读上一段。

生成器

如同其名,生成器生成一个初始位置集(或数组)或演员。这个集合将由测试进行过滤和评估。

生成初始集的方法完全自由。如果您在评估阶段之前有关您正在寻找的地方的重要信息,那么您可以创建一个自定义的生成器(例如,如果智能体不能游泳,则不要检查有水的地方;如果唯一可用的攻击是近战,则不考虑飞行敌人)。

上下文一样,生成器是特定类别的子类。您可以在蓝图以及 C++中创建生成器。

通常,最常用的生成器是网格生成器,它会在一个上下文周围(例如,在智能体周围)生成一个均匀的网格。通过这样做,智能体将检查其周围的大部分区域。

测试

测试负责对由生成器生成的不同位置(或演员)进行过滤评分(评估)。单个测试可以在同一标准上过滤和评分,也可以只进行其中之一。

在使用过滤测试的情况下,它们试图确定哪些位置(或演员)不符合我们的标准。EQS 进行了优化,因此它以特定的顺序执行测试,以尽早检测不合适的地方。这样做是为了避免分配不会使用的分数。

一旦所有位置(或演员)都被过滤掉,剩下的位置将被评估。因此,每个能够分配分数的测试都会在位置(或演员)上执行(执行),以报告评估结果,形式为分数(可以是正数或负数)。

作为旁注,测试需要(至少)一个上下文来正确地进行过滤评估

让我们通过一个简单的测试例子来了解它们是如何工作的。最常见的测试之一是距离,即这个地点(我们正在评估的生成地点)距离上下文有多远?上下文可以是查询者,或它正在攻击的敌人,或任何其他东西。因此,我们可以(例如)过滤距离高于或低于某个距离阈值的地点(例如,如果它们离玩家太远,我们可能不想有完美的掩护地点)。相同的距离测试可以根据距离分配得分,如果上下文远离(或接近),得分可以是正的(或负的)。

此外,一个测试有一个得分因子,它代表测试权重:测试的重要性以及此测试在计算当前评估位置(或演员)的最终得分时需要产生的影响。实际上,您将在由生成器生成的位置上运行许多不同的测试。得分因子允许您轻松权衡它们,以确定哪个测试对位置的最终得分(或演员)有更高的影响。

每个测试在其详细信息面板中的选项具有以下结构:

图片

  • 测试:在这里,您可以选择测试目的是要过滤和得分,还是仅其中之一,并添加描述(对测试没有影响,但您可以将其视为注释来回忆这个测试的内容)。此外,可能还有其他选项,例如可以与导航系统一起使用的投影数据(对于依赖于导航系统的测试)。

  • 特定测试:这是存放测试特定选项的地方。这因测试而异。

  • 过滤:在这里,您可以选择如何设置过滤的行为。这因测试而异,但通常您可以选择一个过滤类型,如果测试将返回值评估为浮点数,则可以是范围(或最小值最大值);否则,在条件测试的情况下,可以是布尔值。如果测试目的设置为仅得分,则此选项卡不会显示。

  • 得分:在这里,您可以选择如何设置得分的行为。这因测试而异。对于测试的浮点返回类型,您可以选择一个得分方程,以及一个归一化。此外,还有得分因子,这是与其他测试相比此测试的权重。对于布尔返回值,只有得分因子。如果测试目的设置为仅过滤,则此选项卡不会显示。

  • 预览:这为您提供了过滤和得分函数的预览。

如您所见,这些选项非常容易理解,如果您使用 EQS 进行练习,您将更好地理解它们。

组件的视觉表示

这些组件一开始可能不太直观,但一旦你习惯了 EQS,你就会意识到它们是如何有意义的,以及为什么系统会以这种方式设计。

为了总结组件及其重要性,以及提供一个视觉表示,这里有一个你可以参考的图表:

在行为树中运行环境查询

最后,要完全理解环境查询是如何工作的,最后一步是看看它如何在行为树中运行。

幸运的是,我们有一个名为“运行 EQS”的节点,这是一个内置的行为树任务。在假设的行为树编辑器中看起来如下:

可能在详细信息面板中找到的可能设置如下:

如你所见,许多已经过时(所以只需忽略它们),但我已经突出显示了最重要的那些。以下是对它们的解释:

  • 黑板键:这是引用包含 EQS 结果的黑板变量的黑板键选择器。

  • 查询模板:对我们要运行的特定 EQS 的引用。否则,我们可以取消激活此选项以激活一个EQSQuery 黑板键

  • 查询配置:这是查询的可选参数(不幸的是,我们在这本书中不会详细讨论它们)。

  • EQSQuery 黑板键:一个黑板键选择器,它引用包含EQS的黑板变量。如果激活,包含在黑板变量中的EQSQuery将被执行,而不是查询模板

  • 运行模式:这显示了我们将要检索的查询结果。可能的选项如下:

    • 最佳单个项目:这会检索得分最高的点(或演员)

    • 从最佳 5%中随机选择单个项目:这会从得分最高的 5%的位置(或演员)中随机检索一个点

    • 从最佳 25%中随机选择单个项目:这会从得分最高的 25%的位置(或演员)中随机检索一个点

    • 所有匹配项:这会检索所有与查询匹配的位置(或演员)(它们尚未被过滤掉)

这就完成了我们如何运行 EQS 以及如何检索其结果,以便在行为树中使用。

当然,还有其他触发 EQSQuery 的方法,这些方法不一定是在行为树中完成的,尽管这是 EQS 最常见的使用方式。不幸的是,我们在这本书中不会涵盖运行 EQSQuery 的其他方法。

不仅位置,还包括演员!

当我说“……评估一个位置**(或演员)……”时,我强调了这一点。

实际上,EQS 最酷的功能之一是能够评估不仅位置,还可以评估演员!

再次强调,您可以将 EQS 用作决策过程。想象一下,您需要先选择一个敌人进行攻击。您可能需要考虑各种参数,例如该敌人的剩余生命值、它的强度以及它在未来立即被视为威胁的程度。

通过仔细设置 EQS,您可以为每个敌人分配一个分数,取决于哪个敌人最方便攻击。当然,在这种情况下,您可能需要做一些工作来创建适当的生成器,以及上下文和适当的测试,但从长远来看,这使得 EQS 在代理需要做出这类决策时成为一个非常好的选择。

探索内置节点

在我们创建自己的生成器、上下文和测试之前,让我们先谈谈内置节点。虚幻引擎自带了一些有用的通用内置节点。我们将在本节中探讨它们。

请记住,本节将分析性地解释 EQS 中每个内置节点的工作原理,就像文档一样。因此,如果您愿意,请将本节用作参考手册,如果您不感兴趣,请跳过这些部分。

内置上下文

由于我们是通过查看上下文来解释 EQS 的,所以让我们从内置上下文开始。当然,制作通用的上下文几乎是一个悖论,因为上下文非常具体于"上下文"(情境)。

然而,虚幻引擎自带了两个内置上下文:

  • EnvQueryContext_Querier:这代表发起查询的 Pawn(精确地说,这并不是发起查询的 Pawn,而是运行行为树并发起查询的控制器,并且这个上下文返回受控 Pawn)。因此,通过使用这个上下文,所有内容都将相对于查询器

如我之前所述,在底层,查询器确实是一个上下文

  • EnvQueryContext_Item:这返回由生成器生成的所有位置。

内置生成器

有许多内置的生成器,大多数情况下,这些将足够您完成大多数所需的 EQS。您只有在有特定需求或希望优化 EQS 时才会使用自定义生成器

大多数这些生成器都很直观,所以我将简要解释它们,并在必要时提供截图,以展示它们生成点的方式。

以下截图使用了一个能够可视化环境查询的特殊 Pawn。我们将在本章的后面学习如何使用它。

这是可用的内置生成器列表,正如您在环境查询编辑器中找到的那样:

图片

为了组织这些信息,我将每个生成器分成一个子节,并将它们按先前的截图中的顺序(按字母顺序)排列。

当我提到 Generator 的设置时,我的意思是,一旦选择了特定的 Generator,在Details Panel中就会显示它的可用选项。

类别演员

这个 Generator 会获取特定类别的所有演员,并将它们的所有位置作为生成的点返回(如果这些演员在 Context 的一定半径内)。

这是在Environmental Query Editor中的样子:

可能的选项包括Searched Actor Class(显然)和从Search Center来的Search Radius(这被表达为Context)。可选地,我们可以检索特定类别的所有演员,并忽略它们是否在Search Radius内:

在前面的截图中,我使用了Querier作为Search CenterSearch Radius50000,以及ThirdPersonCharacter作为Searched Actor Class,因为它们已经在项目中可用。

通过使用这些设置(并放置几个ThirdPersonCharacter演员),我们得到以下情况:

注意围绕三个ThirdPersonCharacter演员的(蓝色)球体。

当前位置

Current Location Generator简单地从Context中检索位置(或它们),并使用它(或它们)来生成点。

这是在Environmental Query Editor中的样子:

对于这个Generator,唯一可用的设置是Query Context

因此,如果我们使用Querier作为Query Context,那么我们只有Querier自己的位置,如下面的截图所示:

复合

Composite Generator允许你混合多个 Generator,以便有更广泛的选择点。

这是在Environmental Query Editor中的样子:

Settings中,你可以设置一个Generators数组:

由于我们没有时间详细地查看所有内容,所以不会进一步介绍这个 Generator。

点:圆形

如其名所示,Circle Generator会在指定半径的圆周上生成点。此外,还提供了与Navmesh交互的选项(这样就不会在Navmesh之外生成点)。

这是在Environmental Query Editor中的样子:

这是一个非常复杂的 Generator,因此这个 Generator 有各种设置。让我们来看看它们:

理想情况下,为每个设置提供一张截图会很好,这样我们可以更好地了解每个设置如何影响点的生成。不幸的是,这本书已经有了很多截图,仅为了这些复杂的生成器的不同设置而专门写一章将花费很多时间和空间。“书空间”。然而,有一种更好的方法让你获得同样的感觉:自己尝试!是的——一旦你学会了如何设置EQSTestingPawn*,你就可以自己尝试,看看每个设置如何影响生成过程。这是你学习和真正理解所有这些设置的最佳方式。

  • 圆半径:正如其名所示,它是圆的半径。

  • 空间间隔:每个点之间应该有多少空间;如果将圆上点间隔方法设置为按空间间隔

  • 点数数量:应该生成多少个点;如果将圆上点间隔方法设置为按点数

  • 圆上点间隔方法:确定要生成的点数是否应根据固定数量的点(按点数)计算,还是根据固定间隔的点数来计算,如果点之间的空间是固定的(按空间间隔)。

  • 弧方向:如果我们只生成圆的弧,此设置确定这个方向应该是怎样的。计算方向的方法可以是两点(它需要两个上下文并计算两点之间的方向)或旋转(它需要一个上下文并检索其旋转,然后根据该旋转决定弧的方向)。

  • 弧角度:如果这与360不同,它定义了点停止生成的地方的切割角度,从而创建一个而不是圆。这种的方向(或旋转)由弧方向参数控制。

  • 圆心:正如其名所示,它是圆的中心,表示为上下文

  • 生成圆时忽略任何上下文演员:如果选中,它将不会考虑用作圆的上下文的演员,从而跳过在这些位置生成点。

  • 圆心 Z 偏移:正如其名所示,它是圆心沿 z 轴的偏移。

  • 追踪数据:在生成圆时,如果有障碍物,通常我们不想在障碍物后面生成点。此参数确定进行"水平"追踪的规则。这些选项如下:

    • :将没有痕迹,所有生成的点都将位于圆上(或弧上)。

    • 导航:这是默认选项。NavMesh结束的地方就是生成点的地方,即使中心距离小于半径(在某种程度上,如果遇到边界,圆会假设NavMesh的形状)。

    • 几何形状:与导航相同,但使用几何形状而不是NavMesh作为边界,追踪将使用级别的几何形状(如果你没有NavMesh,这可能非常有用)。

    • 导航过边缘:与导航相同,但现在追踪是“过边缘”。

  • 投影数据:这与 Trace Data 类似,但通过从上方投影点进行“垂直”追踪。其余部分,概念与Trace Data完全相同。选项是导航,和几何形状,它们在Trace Data中的含义相同。“Navigation Over Ledges”不存在,因为它没有意义。

通过使用前一个屏幕截图中显示的相同设置(我在使用Trace DataNavigation,并且在级别中有NavMesh),这就是它的样子(我使用P键激活了 NavMesh,所以你也能看到它):

通过使用几何形状代替Trace Data,我们得到一个非常相似,但略有不同的形状:

如果你有一个结束的 NavMesh,但没有级别的几何形状,效果会更加明显。

点:圆锥

正如其名所示,圆锥生成器在特定上下文(如聚光灯)的圆锥内生成点。此外,还有与Navmesh交互的选项(这样你就可以将点投影到Navmesh上)。

重要的是要理解,其形状是由许多圆生成的,我们总是取相同的弧。所以,如果我们取整个圆,我们基本上是在生成单个切片的区域中的点。

这个生成器也可以用来生成覆盖整个圆区域的点。

这是在环境查询编辑器中的样子:

其设置主要与圆锥的形状有关,所以让我们来探索它们:

再次强调,最好为每种设置组合都有一张截图,这样你就能感受到每个设置如何影响点的生成。由于我们在这本书中没有足够的空间这样做,我鼓励你使用EQSTestingPawn进行实验,以便你有一个更清晰的理解。

    • 对齐点距离:这是生成点之间的弧距离(从中心相同角度的点之间的距离)。较小的值生成更多的点,考虑到的区域将更加密集。

    • 圆锥度数:这决定了每个圆的弧度大小(我们考虑切片的宽度)。360 的值考虑了整个圆的面积。

    • 角度步长:这是相同弧线点之间的距离,以度为单位。较小的值意味着更多的点,考虑到的区域将更加密集。

    • 范围:这决定了圆锥可以延伸多远(以聚光灯为例,它可以照亮多远)。

    • 中心演员:这是生成的圆的中心,用于确定圆锥。它是中心,并以上下文的形式表示。

    • 包含上下文位置:正如其名所示,如果选中,还会在圆锥/圆的中心生成一个点。

    • 投影数据:通过从上方投影点(考虑几何形状或 导航网格)执行 "垂直" 追踪。实际上,可能的选择是 , 导航,和 几何

使用默认设置,圆锥在关卡中可能看起来是这样的:

图片

点:甜甜圈

正如其名所示, 甜甜圈生成器 以甜甜圈形状(或对那些喜欢数学的人来说是"圆环")生成点,从一个特定的中心开始,该中心作为一个上下文给出。此外,还有各种选项,以便你可以与 导航网格 交互(这样你就可以将点投影到 导航网格 上)。

此生成器还可以用于生成螺旋形状。就像圆锥形状一样,此生成器可以用于生成点来覆盖整个圆的面积。你可以通过将其内半径设置为零来实现这一点。

这是在 环境查询编辑器 中的样子:

图片

可用的以下设置:

图片

  • 内半径:这是甜甜圈的"洞"的半径;在此半径内不会生成任何点(因此它离中心的距离更远)。

  • 外半径:这是整个甜甜圈的半径;点将在内半径和 外半径之间生成环。这也意味着在此半径之外不会生成任何点(因此,它离中心的距离更远)。

  • 环数:在内半径外半径之间应生成多少个点环。这些环总是均匀分布的,这意味着它们的距离由这个变量控制,以及内半径外半径

  • 每环点数:这决定了每个生成的环应该有多少个点。点沿环均匀分布。

  • 弧方向:如果我们只生成甜甜圈的弧(精确地说,只生成将生成甜甜圈的圆的弧),此设置确定这个方向。计算方向的方法可以是 两点(它需要两个 上下文并计算两点之间的方向)或 旋转(它需要一个 上下文并检索其旋转,然后根据该旋转决定弧的方向)。

  • 弧度角:如果这不是360,它定义了点停止生成的地方的切割角度,从而创建一个而不是圆。这种的方向(或旋转)由弧方向参数控制。

  • 使用螺旋模式:如果选中,每个环中的点略有偏移以生成螺旋图案。

  • 中心:这是生成的环的中心(以及用内半径外半径指定的甜甜圈的最小和最大延伸)。它被表示为上下文

  • 投影数据:这通过从上方投影点来执行一个"垂直"追踪,考虑了几何形状或导航网格。可能的选项是导航,和几何

要理解这些设置,请看以下截图:

图片

通过使用这些略微修改的设置(请注意我如何增加了内半径,提高了环数每环点数,并且还使用了导航来投影数据),可以轻松地可视化甜甜圈。以下是使用的设置:

图片

这是它们产生的结果:

图片

通过使用相同的设置,并检查使用螺旋模式,你可以看到不同环中的点略有偏移,从而创建一个螺旋图案:

图片

点:网格

如其名所示,网格生成器在网格内生成点。此外,还有与导航网格交互的选项(这样你就不必在导航网格外生成点)。

这是在环境查询编辑器中的样子:

图片

这个生成器的设置相当简单:

图片

  • 网格半尺寸:网格应从其中心延伸多远(这意味着它是完整网格大小的一半)。网格的尺寸完全由这个参数以及行间距决定。

  • 行间距:网格的每一行和每一列之间的空间大小。网格的尺寸完全由这个参数以及网格半尺寸决定。

  • 生成区域:这是网格的中心(生成开始的地方),它被表示为上下文

  • 投影数据:这通过从上方投影点来执行一个"垂直"追踪。它是通过考虑几何形状或导航网格来做到这一点的。可能的选项是导航,和几何

通过查看设置,你可以看到这个生成器相当简单,但功能强大且非常常用。使用默认设置,在关卡中的样子如下(在 Navmesh 中启用了投影,并在地图中存在):

点:路径网格

正如其名所示,路径网格生成器在网格内生成点,就像网格生成器一样。然而,这个生成器的不同之处在于,路径网格生成器会检查点是否可以通过在生成周围设置(通常为查询者)中指定的上下文(通常为查询者),在指定距离内到达。

这是在环境查询编辑器中的样子:

这个生成器的设置几乎与点:网格生成器相同:

  • 项目路径:如果选中,则在查询者的设置中排除所有从上下文不可达的点。

  • 导航过滤器:正如其名所示,它是用于执行路径查找的导航过滤器。

  • 网格半尺寸:这表示网格应从其中心延伸多远(这意味着它是完整网格大小的一半)。网格的尺寸完全由这个参数确定,以及空间间隔

  • 空间间隔:这表示网格的每一行和每一列之间的空间大小。网格的尺寸完全由这个参数确定,以及网格半尺寸

  • 生成周围:这是网格的中心(它是开始生成的地方),并以上下文的形式表示。

  • 投影数据:通过从上方投影点来执行一个“垂直”追踪。它通过考虑几何形状或导航网格来完成此操作。可能的选项是导航几何

这就是它在环境中的样子(我稍微调整了级别以阻断楼上的路径。这清楚地表明,那些在楼梯之后的不可达的点甚至不是由这个生成器生成的):

内置测试

现在我们已经探索了所有生成器,是时候探索引擎内可用的不同测试了。通常,返回值可以是布尔值或浮点值。

返回浮点值的测试通常用于评分,而返回布尔值的测试则更常用于过滤。然而,每个测试可能都有不同的返回值,这取决于测试是用于过滤还是评分。

这是可能的内置测试列表;让我们来探索它们:

  • 距离:计算项目(生成的点)与特定上下文(例如查询者)之间的距离。它可以在3D2D、沿 z 轴或沿 z 轴(绝对)计算。返回值是一个浮点数。

  • 点积:计算线 A线 B之间的点积。这两条线都可以表示为两个上下文之间的线或作为特定上下文的旋转(通过取旋转的前向方向)。计算可以在3D2D中进行。

  • 游戏标签:在游戏标签上执行一个查询

  • 重叠:与一个盒子执行重叠测试;可以指定一些选项,例如偏移量或扩展,或重叠通道。

  • 路径查找:在正在评估的生成点和上下文之间执行路径查找。特别是,我们可以指定返回值是一个布尔值(如果路径存在)或一个浮点数(路径成本或甚至路径长度)。此外,我们可以指定路径是否从上下文或相反,并且可以使用导航过滤器

  • 路径查找批量处理:与路径查找相同,但以批量的形式。

  • 项目:执行一个投影,可以通过不同的参数进行自定义。

  • 追踪:执行一个追踪测试,提供所有可能的选项以在引擎的其他地方执行追踪。这意味着它可以追踪一条线、一个盒子、一个球体或一个胶囊;无论是在可见性相机追踪通道上;无论是复杂还是简单;无论是从上下文到点,还是相反。

这就结束了我们对内置节点的探索。

可视化环境查询

如我们之前提到的,有一个简单内置的方法可以在游戏世界中可视化环境查询,直接从视口进行;游戏甚至不需要运行。事实上,有一个特殊的 Pawn 能够做到这一点。然而,这个 Pawn 不能直接带入关卡,因为它已被在代码库中声明为虚拟,以确保它不会被误用。这意味着要使用它,我们需要创建自己的蓝图 Pawn,该 Pawn 直接从这个特殊的 Pawn 继承。

幸运的是,经过这一步,Pawn 就完全功能化了,不再需要任何更多的代码,只需要与参数一起工作(即你想要可视化的环境查询)。

要开始,创建一个新的蓝图。要继承的类是EQSTestingPawn,如下面的截图所示:

然后,你可以将其重命名为MyEQSTestingPawn

如果你将其拖入地图,从详细信息面板,你可以更改EQS设置,如下面的截图所示:

最重要的参数是 查询模板,在其中你指定要可视化的查询。如果你想要深入了解参数,请查看 第十二章,调试 AI 的方法——导航、EQS 和性能分析

为环境查询系统创建组件

在本节中,我们将学习需要扩展哪个类来在 环境查询系统 中创建我们的自定义组件。

创建上下文

创建自定义 上下文 对于在环境查询过程中需要正确引用时至关重要。特别是,我们将创建一个简单的上下文来检索单个玩家的引用。

让我们探索如何在 C++ 和蓝图 中创建这个 上下文

在蓝图中创建玩家上下文

要创建上下文,我们需要从 EnvQueryContext_BlueprintBase 类继承。在蓝图的情况下,在其创建时,只需选择突出显示的类,如下截图所示:

图片

至于名称,惯例是保留前缀 "EnvQueryContext_"。我们可以将我们的上下文命名为 "EnvQueryContext_BPPlayer"。

对于蓝图上下文,你可以选择实现以下函数之一:

图片

每个生成器都将为 环境查询 提供一个上下文。

我们可以重写“提供单个演员”函数,然后返回玩家 Pawn,就这么简单:

图片

因此,我们现在有一个能够获取玩家引用的上下文。

在 C++ 中创建玩家上下文

在创建 C++ 上下文的情况下,从 EnvQueryContext 类继承,如下截图所示:

图片

惯例相同,即用 "EnvQueryContext_" 前缀来命名上下文。我们将把我们的类命名为 "EnvQueryContext_Player*"。

图片

在 C++ 中,只有一个函数需要重写:ProvideContext()。因此,我们只需在 .h 文件中重写它,如下所示:

#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryContext.h"
#include "EnvQueryContext_Player.generated.h"

/**
 * 
 */
UCLASS()
class UNREALAIBOOK_API UEnvQueryContext_Player : public UEnvQueryContext
{
  GENERATED_BODY()

  virtual void ProvideContext(FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const override;
};

在实现文件中,我们可以提供上下文。我不会深入讲解如何实现——你可以阅读其他上下文的代码来帮助你理解这一点。无论如何,我们的 .cpp 文件可以像下面这样(我可以选择不同的实现方式,但我选择了这种方式,因为它更容易理解):

#include "EnvQueryContext_Player.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Actor.h"
#include "Runtime/Engine/Classes/Kismet/GameplayStatics.h"
#include "Runtime/Engine/Classes/Engine/World.h"

void UEnvQueryContext_Player::ProvideContext(FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const
{
  if (GetWorld()) {
    if (GetWorld()->GetFirstPlayerController()) {
      if (GetWorld()->GetFirstPlayerController()->GetPawn()) {
        UEnvQueryItemType_Actor::SetContextHelper(ContextData, GetWorld()->GetFirstPlayerController()->GetPawn());
      }
    }
  }

}

因此,我们能够在 C++ 中检索玩家上下文。

创建生成器

与创建上下文的方式类似,我们可以创建自定义生成器。然而,我们不会详细介绍这一点,因为它们超出了本书的范围。

在蓝图的情况下,从 EnvQueryGenerator_BlueprintBase 类继承,如下截图所示:

图片

在 C++中,您需要从EnvQueryGenerator类继承:

图片

由于您已经拥有了所有投影,您可能希望直接从EnvQueryGenerator_ProjectedPoints开始。通过这样做,您只需关注其生成。

创建测试

在当前版本的虚幻引擎中,您无法在蓝图(Blueprint)中创建测试——我们只能用 C++来实现。您可以通过扩展EnvQueryTest类来完成这项工作:

图片

不幸的是,这也不在本书的范围之内。然而,探索虚幻引擎的源代码将为您提供大量的信息和几乎无穷无尽的学习资源。

摘要

在本章中,我们探讨了环境查询系统如何使决策领域的空间推理成为可能。

具体来说,我们了解了整个系统的一般工作原理,然后我们逐一了解了系统的内置节点。我们还看到了如何通过一个特殊的棋子来可视化查询。最后,我们探讨了如何扩展系统。

在下一章中,我们将探讨代理意识以及内置的感知系统。

第五章:智能体意识

你可以跑,但你无法藏!

哦,你回来了?太好了,因为这意味着你的眼睛捕捉到了一些光线信息,你的大脑正在感知,这通常被称作阅读。我们做的每一件事,我们做的每一个决定,都是基于我们的感知,从生物学角度来看,我们做出快速的决定过程,因为时间是至关重要的(例如,你看到一条蛇,你的杏仁核处理这些信息比你的视觉皮层要快得多和快!)

同样的概念,AI 需要通过收集他们需要感知的信息来基于事实做出决策。本章全部关于感知,以及 AI 如何从环境中获取这些信息,以便它能够意识到其周围的环境。我们在上一章中探讨了 EQS,它收集了大量关于周围环境的信息并进行处理。在这里,我们将仅限于简单的感知行为。AI 将如何使用这些信息是其他章节(以及我们已经讨论过的章节)的主题。

下面是本章我们将要讨论的主题的简要概述:

  • 现有视频游戏中的感知和意识

  • Unreal 中感知系统的概述

  • 感知组件

  • 视觉和听觉感知

  • 感知刺激

  • 在智能体中实现视觉(在蓝图和 C++中)

  • 在智能体中实现听觉(在蓝图和 C++中)

那么,让我们先看看一些关于视频游戏中 AI 意识的例子,然后我们将看到如何在 Unreal 中设置感知系统。

游戏中的人工智能感知

这一切都是感知的问题,对吧?但是当涉及到人工智能——特别是游戏中的 AI——感知可以决定胜负。换句话说,AI 角色在游戏过程中如何感知玩家可以创造一系列不同的体验,从而在你缓慢、试探性地转弯时,营造出充满紧张和悬念的环境。

声音

你有没有尝试在游戏中悄悄绕过守卫,试图不发出声音或被发现?这是 AI 感知玩家并相应反应(通常不是对你有利)的最常见方式之一!然而,使用声音来影响 AI 对玩家的感知的好处是,它给了玩家发起突袭的机会(例如,《杀手》,《刺客信条》)。例如,玩家可以悄悄地接近敌人,从后面击晕或攻击他们,从而为玩家提供优势。这在敌人难以击败或玩家资源不足(例如,弹药、医疗包/药水等)时尤其有用。

脚步声

正如前面的例子所暗示的,AI 通过声音感知角色的最常见方式之一是通过脚步声。这里没有关于如何做到的惊喜,但检测的邻近性可能取决于许多因素。例如,一些角色可以蹲着走以避免被检测到,或者简单地通过潜行(例如 Abe's Oddyssey);其他游戏允许某些角色在移动时不可检测,除非被敌人视觉上发现(例如 Resident Evil: Revelations 2 中的 Natalia)。使用脚步声作为 AI 感知的触发器的另一个关键因素是玩家行走的地面材料类型。例如,一个在森林中行走的玩家,踩在树叶和树皮上,会比在沙地上行走的玩家更明显(也更响亮)。

撞倒物体

当你在关卡中潜行或蹲着走,甚至是在卧姿(例如 Battlefield)时,不会触发敌人,但如果你撞倒了某个东西(例如瓶子、箱子、随机物品),它很可能会引起他们的注意。在这种情况下,环境物体在 AI 通过玩家在环境中的笨拙动作来感知玩家位置方面发挥着重要作用。在某些情况下,某些物体可能比其他物体更容易吸引注意力,这取决于它们产生的噪音大小。当然,作为游戏设计师,你有权决定这一点!

位置

类似于声音,AI 可以根据你与它们之间的距离看到你。当你被敌人直接看到时,这种情况会更明显,也更难以避免。想象一下,你正在悄悄地绕过敌人,一旦你足够接近他们,那就完了,你已经被发现!这是许多玩家面临的悲惨危险,但也是一个有很多回报的事情,尤其是在战胜敌人的满足感方面。

让我们通过一些例子进一步探讨这个概念。首先,我们有像 Assassin's CreedHitman: AbsolutionThief 这样的游戏,在这些游戏中,通过操纵来避开敌人的艺术对于玩家完成任务的成败至关重要。通常,这要求玩家利用环境周围的环境,如 NPC、墙壁、干草堆、植物(树木、灌木)、屋顶,以及利用惊喜元素。

距离区域

在其他情况下,有一个明确的距离区域,玩家可以在被检测到之前保持在这个区域之外。在游戏中,这通常通过光源,如手电筒来体现,迫使玩家在阴影和光线之间穿梭以避免被检测到。采用这种方法的优秀游戏例子有 Monaco: What's Yours Is MineMetal Gear Solid,其中某些 AI 角色通过火炬或长时间面对玩家来获得可见性。

你可以在下面的屏幕截图中看到这个例子:

图片

游戏截图来自《摩纳哥:你的就是我的》

在这里(在《摩纳哥:你的就是我的》中),你可以看到手电筒的半径,一旦玩家进入,他们就有有限的时间来吸引警卫的注意。

由于《摩纳哥:你的就是我的》完全基于这种机制,让我们看看更多截图,以更好地了解这款游戏中视觉感知的工作方式。

在下面的截图中,我们可以看到当玩家改变房间时感知是如何变化的:

图片

游戏截图来自《摩纳哥:你的就是我的》

在下面的截图中,我们看到了玩家的感知特写:

图片

游戏截图来自《摩纳哥:你的就是我的》

然后,我们看到了一名警卫手电筒的特写:

图片

游戏截图来自《摩纳哥:你的就是我的》

改变游戏,在《合金装备》中,感知与敌人(红色圆点)在玩家(白色圆点)周围巡逻环境的方式相似。在下面的截图中,你可以看到一个摄像头(在小地图中以红色圆点表示)在小地图中有一个黄色的视野锥(警卫有一个蓝色的视野锥):

图片

游戏截图来自《合金装备》

《合金装备》游戏系列完全基于感知,如果你对使用这种机制开发游戏 AI 感兴趣,那么探索更多并了解这款游戏是值得的。

总结一下,如果你离 NPC(例如,在他们的可视范围内)太近,你会被发现,他们会尝试与你的人物互动,无论是好是坏(例如《刺客信条》中的乞丐或敌人攻击你),这解锁了许多基于感知的有趣机制。

与其他敌人的互动

一个 AI 对你的位置的感知并不一定与你进入他们的可视区域的时间有关。在其他情况下(例如第一人称射击游戏),这可能会在你开始射击敌人时发生。这会在你的初始近距离内的许多 AI 中产生连锁反应,它们会以你为目标(例如《合金装备》、《双雄》、《战地》等)。

并非所有都与“敌人”有关

在许多体育游戏中,AI 必须具有感知能力才能相应地做出反应,例如防止进球、击球或投篮。在体育游戏中,AI 在与你对抗时必须具有感知能力(和竞争力)。他们需要知道你的位置和球的位置(或任何其他物体),以便他们可以做出反应(例如将球踢离球门柱)。

感知 AI 不仅仅是人形或动物性的

感知 AI 也可以包括机器,例如汽车和其他车辆。以游戏《侠盗猎车手》、《赛车手》和《逃离》为例,这些游戏要求玩家在车内某个时刻在 3D 世界空间中导航。在某些情况下,车内有 NPC,但大部分情况下,汽车本身会对你驾驶做出反应。这种情况也适用于更多以运动为导向的游戏,如《极品飞车》、《速度与激情》和《 Ridge Racer》(仅举几个例子)。

玩家的影响

正如我们所看到的,AI 检测玩家的方式有很多种。但在所有这些中,游戏设计师必须考虑的是这将对游戏体验产生怎样的影响;它将如何驱动游戏玩法?虽然感知 AI 的使用对任何游戏来说都是一个很好的补充,但它也会影响游戏玩法。例如,如果你想有一个高度关注技能、玩家敏捷性和更多环境意识的玩法,那么 AI 的感知需要非常敏感,玩家将更加脆弱(例如,盗贼)。但另一方面,如果你想有一个快节奏的动作游戏,你需要有一个平衡的感知 AI,允许玩家相应地做出反应。例如,他们有一个与 AI 对抗的公平竞技场。

感知系统概述

回到虚幻引擎,正如你所预期的那样,AI 框架中有一个子系统实现了 AI 感知。再次强调,你可以自由地实现自己的系统,尤其是如果你有特殊需求的话…

感知与感知方面,我们处于比决策(如行为树和 EQS)更低的层次。实际上,这里没有需要做出的决策,没有需要选择的地方,而只是信息的流动/流程。

如果感知系统感知到某些“有趣”的东西(我们稍后会定义这是什么意思),那么它会通知 AI 控制器,AI 控制器将决定如何处理收到的刺激(这在虚幻引擎术语中是其感知)。

因此,在本章中,我们将重点介绍如何正确设置感知系统,以便我们的 AI 能够感知,但我们不会处理收到刺激后的操作(例如,玩家在视线中,开始追逐他们)。毕竟,如果你已经有了准备好的行为(例如,追逐玩家的行为树;我们将在本书后面构建这样的树),感知背后的逻辑简单到“如果玩家在视线中(AI 控制器从感知系统中收到刺激),则执行追逐行为树”。

在实际应用中,虚幻引擎内置的感知系统主要基于两个组件的使用:AIPerceptionComponentAIPerceptionStimuliSourceComponent。前者能够感知刺激,而后者能够产生刺激(但产生刺激的方式不止这一种,我们很快就会看到)。

虽然听起来可能有些奇怪,但系统认为 AIPerceptionComponent 是附加到 AI 控制器上的(而不是它们所控制的 Pawn/Character)。实际上,是 AI 控制器将根据接收到的刺激做出决定,而不是单纯的 Pawn。因此,AIPerceptionComponent 需要直接附加到 AI 控制器上。

AIPerceptionComponent

让我们分解一下 AIPerceptionComponent 的工作原理。我们将同时在蓝图和 C++ 中进行这一操作。

蓝图中的 AIPerceptionComponent

如果我们打开蓝图 AI 控制器,我们就可以像添加任何其他组件一样添加 AIPerceptionComponent:从组件选项卡,点击 添加组件 并选择 AIPerceptionComponent,如下面的截图所示:

截图

当你选择组件时,你将看到它在 详细信息 面板中的样子,如下面的截图所示:

截图

它只有两个参数。一个定义了主要感官。实际上,AIPerceptionComponent 可以拥有多个感官,当涉及到检索已感知的目标位置时,AI 应该使用哪一个?主要感官通过给予一个感官相对于其他感官的优先权来消除歧义。另一个参数是一个感官数组。当你将不同的感官填充到数组中时,你将能够自定义每一个,如下面的截图所示:

截图

请记住,你可以拥有每种类型超过一个感官。假设你的敌人有两个头,朝向不同的方向:你可能想要有两个视觉感官,一个对应每个头。当然,在这种情况下,需要更多的设置来确保它们正确工作,因为你需要修改视觉组件的工作方式,比如说,AI 总是从其前向矢量观察。

每个感官都有自己的属性和参数。让我们来看两个主要的:视觉和听觉。

感官 – 视觉

视觉感官的工作方式正如你所期望的,并且它几乎可以直接使用(这可能不适用于其他感官,但视觉和听觉是最常见的)。它看起来是这样的:

截图

让我们分解一下控制视觉感官的主要参数:

  • 视线半径:如果一个目标(一个可以看到的对象)进入这个范围内,并且没有被遮挡,那么目标就会被检测到。在这种情况下,它就是“最大视线距离以注意到目标”。

  • 失去视线半径:如果目标已经被看到,那么如果未被遮挡,目标仍然会在这个范围内被看到。这个值大于 视线半径,这意味着如果目标已经被看到,AI 能够在更远的距离上感知到目标。在这种情况下,它就是“最大视线距离以注意到已经看到的目标”。

  • 外围视野半角度数:正如其名所示,它指定了 AI 可以看多远(以度为单位)。90 的值意味着(因为这个值只是角度的一半)AI 能够看到其前方直到 180 度的所有事物。180 的值意味着 AI 可以朝任何方向看;它有 360 度的视野。此外,重要的是要注意,这个半角是从前进向量测量的。以下图表说明了这一点:

图片

  • 从上次看到的位置自动成功范围:默认情况下,它设置为无效值(-1.0f),这意味着它没有被使用。这指定了从目标上次看到的位置的范围,如果它在这个范围内,则目标总是可见的。

有其他一些更通用的设置,可以应用于许多感官(包括听觉,因此它们将在下一节中不再重复):

  • 通过隶属关系检测参见不同的团队部分。

  • 调试颜色:正如其名所示,这是在视觉调试器中显示此感官的颜色(参见第十一章[de51b2fe-fb19-4347-8de9-a31b2c2a6f2f.xhtml],AI 调试方法 – 记录,获取更多信息)。

  • 最大年龄:它表示刺激被记录的时间(以秒为单位)。想象一下,一个目标从 AI 的视野中消失;它的最后位置仍然被记录,并分配了一个年龄(这些数据有多久)。如果年龄大于最大年龄,则刺激将被删除。例如,一个 AI 正在追逐玩家,玩家逃离了他的视野。现在,AI 应该首先检查玩家最后被看到的位置,试图将其带回视野中。如果失败,或者位置记录了许多分钟之前,那么这些数据就不再相关,可以将其删除。总之,这指定了由这个感官产生的刺激被遗忘后的年龄限制。此外,0 的值表示永不。

感官 – 听觉

听觉感官只有一个合适的参数,即听觉范围。这设置了 AI 能够听到的距离。其他的是我们已经看到的通用参数(例如最大年龄调试颜色通过隶属关系检测)。它看起来是这样的:

图片

为了使这本书完整,值得提一下,还有一个选项,称为LoSHearing。据我所知,通过查看虚幻引擎源代码(版本 4.20),这个参数似乎不影响任何事物(除了调试)。因此,我们将其设置为未启用。

在任何情况下,都有其他选项来控制声音的产生。实际上,听觉事件需要使用特殊的功能/蓝图节点手动触发。

AIPerceptionComponent 和 C++中的感官

如果你跳过了前面的部分,请先阅读它们。实际上,所有概念都是相同的,在这个部分,我只是将要展示组件在 C++ 中的使用,而不会重新解释所有概念。

这里是我们想要使用的类的 #include 语句(我只包含了视觉和听觉):

#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISense_Sight.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISense_Hearing.h"
#include "Perception/AISenseConfig_Hearing.h"

要将组件添加到 C++ AI 控制器中,就像添加任何其他组件一样,在 .h 文件中添加一个变量。因此,为了跟踪它,你可以使用基类中的 inerith 变量,它声明如下:

  UPROPERTY(VisibleDefaultsOnly, Category = AI)
  UAIPerceptionComponent* PerceptionComponent;

因此,你可以在任何 AIController 中使用这个变量,而无需在头文件(.h)中声明它。

然后,在 .cpp 文件构造函数中的 CreateDefaultSubobject() 函数,我们可以创建组件:

PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("SightPerceptionComponent"));

此外,你将需要额外的变量,每个你想要配置的感官都需要一个。例如,对于视觉和听觉感官,你需要以下变量:

UAISenseConfig_Sight* SightConfig;
UAISenseConfig_Hearing* HearingConfig;

要配置一个感官,你首先需要创建它,你可以访问它的所有属性,并设置你需要的内容:

//Create the Senses
SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(FName("Sight Config"));
HearingConfig = CreateDefaultSubobject<UAISenseConfig_Hearing>(FName("Hearing Config"));

//Configuring the Sight Sense
SightConfig->SightRadius = 600;
SightConfig->LoseSightRadius = 700;

//Configuration of the Hearing Sense
HearingConfig->HearingRange = 900;

最后,你需要将感官绑定到 AIPerceptionComponent

//Assigning the Sight and Hearing Sense to the AI Perception Component
PerceptionComponent->ConfigureSense(*SightConfig);
PerceptionComponent->ConfigureSense(*HearingConfig);
PerceptionComponent->SetDominantSense(SightConfig->GetSenseImplementation());

如果你需要调用回事件,你可以通过绕过回调函数(它必须具有相同的签名,不一定是相同的名称)来这样做:

//Binding the OnTargetPerceptionUpdate function
PerceptionComponent->OnTargetPerceptionUpdated.AddDynamic(this, &ASightAIController::OnTargetPerceptionUpdate);

这就结束了在 C++ 中使用 AIPerceptionComponentSenses 的过程。

不同团队

就内置在 Unreal 中的 AI 感知系统而言,AI 和任何可以被检测到的内容都可以有一个团队。有些团队是相互对立的,而有些则是中立的。因此,当 AI 来感知某物时,那物可以是友好的(它在同一个团队中),中立的,或者敌人。例如,如果一个 AI 在巡逻营地,我们可以忽略友好的和中立的实体,只专注于敌人。顺便说一句,默认设置是只感知敌人。

你可以通过 SenseDetecting for Affiliation 设置来改变 AI 可以感知的实体类型:

这提供了三个复选框,我们可以选择我们希望 AI 感知的内容。

总共有 255 个团队,默认情况下,每个实体都在团队 255(唯一的特殊团队)中。在团队 255 中的实体被视为中立(即使两个实体都在同一个团队中)。否则,如果两个实体在同一个团队中(不同于 255),它们“看到”对方是友好的。另一方面,两个不同团队(不同于 255)的实体“看到”对方是敌人。

现在的问题是,我们如何更改团队?目前,这只能在 C++ 中完成。此外,我们已经讨论了实体,但谁实际上可以成为团队的一员?所有实现了 IGenericTeamAgentInterface 的内容都可以成为团队的一部分。AIControllers 已经实现了它。因此,在 AI 控制器上更改团队很容易,如下面的代码片段所示:

    // Assign to Team 1
    SetGenericTeamId(FGenericTeamId(1));

对于其他实体,一旦它们实现了IGenericTeamAgentInterface,它们就可以重写GetGenericTeamId()函数,这为 AI 提供了一种检查该实体属于哪个团队的方法。

AIStimuliSourceComponent

我们已经看到了 AI 如何通过感知来感知,但刺激最初是如何生成的?

所有 Pawns 都会自动检测。

在视觉感知的情况下,默认情况下,所有 Pawns 已经是刺激源。实际上,在本章的后面,我们将使用玩家角色,该角色将由 AI 检测,而无需AIStimuliSourceComponent。如果你有兴趣禁用此默认行为,你可以通过进入你的项目目录,然后进入Config文件夹来实现。在那里,你会找到一个名为DefaultGame.ini的文件,你可以在其中设置一系列配置变量。如果你在文件末尾添加以下两行,Pawns 将默认不产生视觉刺激,并且它们还需要AIStimuliSourceComponent以及所有其他内容:

[/Script/AIModule.AISense_Sight]
bAutoRegisterAllPawnsAsSources=false

在我们的项目中,我们不会添加这些行,因为我们想要 Pawns 被检测,而无需添加更多组件。

AIStimuliSourceComponent 在蓝图中的使用

和其他组件一样,它可以添加到蓝图:

图片

如果你选择它,你将在详细信息面板中看到它只有两个参数:

  • 自动注册为源:正如其名所示,如果选中,源将自动在感知系统中注册,并且它将从一开始就开始提供刺激。

  • 注册为感知源:这是一个数组,包含该组件提供的所有感知刺激。

关于这个组件没有太多可说的。它非常简单易用,但很重要(你的 AI 可能无法感知任何刺激!)。因此,当你想要它们生成刺激(这可能只是被 AI 看到)时,记得将其添加到非 Pawns 实体中。

C++中的 AIStimuliSourceComponent

在 C++中使用此组件很容易,因为你只需创建它、配置它,它就准备好了。

这是你需要使用的#include语句,以便你可以访问该组件的类:

#include "Perception/AIPerceptionStimuliSourceComponent.h"

和其他组件一样,你需要在.h文件中添加一个变量来跟踪它:

UAIPerceptionStimuliSourceComponent* PerceptionStimuliSourceComponent;

接下来,你需要在构造函数中使用CreateDefaultSubobject()函数来生成它:

PerceptionStimuliSourceComponent = CreateDefaultSubobject<UAIPerceptionStimuliSourceComponent>(TEXT("PerceptionStimuliComponent"));

然后,你需要注册一个源感知,如下所示(在这个例子中是视觉,但你可以将TSubClassOf<UAISense>()更改为你需要的感知):

PerceptionStimuliSourceComponent->RegisterForSense(TSubclassOf<UAISense_Sight>());

自动注册为源布尔值是受保护的,默认为 true。

实践感知系统 – 视觉 AI 控制器

了解某物的最佳方式就是使用它。所以,让我们先创建一个简单的感知系统,当有东西进入或离开 AI 的感知区域时,我们在屏幕上打印出来,同时显示当前看到的对象数量(包括/不包括刚刚进入/离开的对象)。

再次强调,我们将这样做两次,一次使用蓝图,另一次使用 C++,这样我们就可以了解两种创建方法。

一个蓝图感知系统

首先,我们需要创建一个新的 AI 控制器(除非你想要继续使用我们之前一直在使用的那个)。在这个例子中,我将称它为“SightAIController”。打开蓝图编辑器,添加 AIPerception 组件,并且如果你喜欢的话,可以将其重命名为“SightPerceptionComponent”。

选择这个组件。在详细信息面板中,我们需要将其添加为视野的一个感知,如下面的截图所示:

图片

我们可以将视野半径失去视野半径设置为合理的值,例如600700,这样我们就可以得到类似这样的效果:

图片

我们可以保持角度不变,但我们需要更改通过归属检测。实际上,在蓝图上无法更改团队,所以玩家将处于相同的 255 号团队,这是一个中立团队。由于我们只是刚开始了解这个系统的工作原理,我们可以勾选所有三个复选框。现在,我们应该有类似这样的效果:

图片

在组件的底部,我们应该有所有不同的事件。特别是,我们需要目标感知更新,每次目标进入或退出感知区域时都会调用它——这正是我们所需要的:

图片

点击图中的“+”符号来添加事件:

图片

这个事件将为我们提供导致更新并创建刺激的演员(值得记住的是,感知组件可能同时有多个感知,这个变量告诉你哪个刺激导致了更新)。在我们的例子中,我们只有视野,所以它不可能是其他任何东西。下一步是了解我们有多少目标在视野中,以及哪个目标离开了或进入了视野。

因此,将SightPerceptionComponent拖入图中。从那里,我们可以拖动一个引脚来获取所有的“当前感知到的演员”,这将给我们一个演员数组。别忘了将感知类设置为视野

图片

通过测量这个数组的长度,我们可以得到当前感知到的演员的数量。此外,通过检查从事件传递过来的演员是否在当前“可见演员”数组中,我们可以确定这样的演员是否已经离开或进入了视野:

图片

最后一步是将所有这些信息格式化成一个漂亮的格式化字符串,以便可以在屏幕上显示。我们将使用 Append 节点来构建字符串,以及一个用于选择 "进入" 或 "离开" 实体的选择器。最后,我们将最终结果连接到 Print String

图片

Print String 仅用于调试目的,在发布游戏时不可用,但我们现在只是在测试和理解感知系统的工作原理。

此外,我知道当感知到的实体数量为一个是,字符串将产生 "1 objects",这是不正确的,但修正复数(尽管可能,无论是使用 if 语句还是以更复杂的方式处理语言结构)超出了本书的范围。这就是为什么我使用这个表达式的理由。

保存 AI 控制器并返回到关卡。如果你不想在 C++ 中做同样的事情,请跳过下一节,直接进入 "测试一切"。

C++ 感知系统

再次,如果你更倾向于 C++ 方面,或者想要实验如何用 C++ 构建相同的 AI 控制器,这部分就是为你准备的。我们将遵循完全相同的步骤(或多或少),而不是图片,我们将有代码!

让我们从创建一个新的 AIController 类开始(如果你不记得如何做,可以查看第二章,在 AI 世界中迈出第一步)。我们将将其命名为 SightAIController 并将其放置在 AIControllers 文件夹中。

让我们开始编辑 SightAIController.h 文件,其中我们需要包含一些其他的 .h 文件,以便我们的编译器知道我们需要的类的实现位置。实际上,我们需要访问 AIPerceptionAISense_Config 类。因此,在你的代码文件顶部,你应该有以下的 #include 语句:

#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISense_Sight.h"
#include "Perception/AISenseConfig_Sight.h"
#include "SightAIController.generated.h"

然后,在我们的类中,我们需要保留对 AIPerception 组件的引用,以及一个将保存视觉感知配置的额外变量:

 //Components Variables
 UAIPerceptionComponent* PerceptionComponent;
 UAISenseConfig_Sight* SightConfig;

此外,我们需要添加 Constructor 函数,以及 OnTargetPerceptionUpdate 事件的回调。为了使其工作,最后一个必须是一个 UFUNCTION(),并且需要有一个 Actor 和一个 AIStimulus 作为输入。这样,反射系统才能按预期工作:

//Constructor
 ASightAIController();

//Binding function
 UFUNCTION()
 void OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus);

让我们进入我们的 .cpp 文件。首先,我们需要创建 AIPerception 组件,以及一个视觉配置:

ASightAIController::ASightAIController() {
 //Creating the AI Perception Component
 PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("SightPerceptionComponent"));
 SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(FName("Sight Config"));

}

然后,我们可以使用相同的参数配置 Sight Sense视野半径600失去视野半径700

ASightAIController::ASightAIController() {
  //Creating the AI Perception Component
  PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("SightPerceptionComponent"));
  SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(FName("Sight Config"));

 //Configuring the Sight Sense
 SightConfig->SightRadius = 600;
 SightConfig->LoseSightRadius = 700;
}

接下来,我们需要检查 DetectionByAffiliation 的所有标志,以便检测我们的玩家(因为,目前,他们都在第 255 个团队中;查看 练习 部分了解如何改进这一点):

ASightAIController::ASightAIController() {
  //Creating the AI Perception Component
  PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("SightPerceptionComponent"));
  SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(FName("Sight Config"));

  //Configuring the Sight Sense
  SightConfig->SightRadius = 600;
  SightConfig->LoseSightRadius = 700;
 SightConfig->DetectionByAffiliation.bDetectEnemies = true;
 SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
 SightConfig->DetectionByAffiliation.bDetectFriendlies = true;
}

最后,我们将视觉配置与AIPerception组件关联起来,并将OnTargetPerceptionUpdate函数绑定到AIPerceptionComponent上的同名列事件:

ASightAIController::ASightAIController() {
  //Creating the AI Perception Component
  PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("SightPerceptionComponent"));
  SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(FName("Sight Config"));

  //Configuring the Sight Sense
  SightConfig->SightRadius = 600;
  SightConfig->LoseSightRadius = 700;
  SightConfig->DetectionByAffiliation.bDetectEnemies = true;
  SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
  SightConfig->DetectionByAffiliation.bDetectFriendlies = true;

 //Assigning the Sight Sense to the AI Perception Component
 PerceptionComponent->ConfigureSense(*SightConfig);
 PerceptionComponent->SetDominantSense(SightConfig->GetSenseImplementation());

 //Binding the OnTargetPerceptionUpdate function
 PerceptionComponent->OnTargetPerceptionUpdated.AddDynamic(this, &ASightAIController::OnTargetPerceptionUpdate);
}

这就结束了构造函数,但我们仍然需要实现OnTargetPerceptionUpdate()函数。首先,我们需要检索所有当前感知到的演员。这个函数需要一个演员数组来填充,以及要使用的感知实现。

因此,我们的数组将填充感知到的演员:

void ASightAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus)
{
 //Retrieving Perceived Actors
 TArray<AActor*> PerceivedActors;
 PerceptionComponent->GetPerceivedActors(TSubclassOf<UAISense_Sight>(), PerceivedActors);

}

通过测量数组的长度,我们可以得到当前感知到的演员数量。此外,通过检查从事件传递过来的演员(函数的参数)是否在当前"可见演员"数组中,我们可以确定这样的演员是否已经离开或进入了视野:

void ASightAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus)
{
  //Retrieving Perceived Actors
  TArray<AActor*> PerceivedActors;
  PerceptionComponent->GetPerceivedActors(TSubclassOf<UAISense_Sight>(), PerceivedActors);

 //Calculating the Number of Perceived Actors and if the current target Left or Entered the field of view.
 bool isEntered = PerceivedActors.Contains(Actor);
 int NumberObjectSeen = PerceivedActors.Num();

}

最后,我们需要将此信息打包到一个格式化的字符串中,然后将其打印到屏幕上:

void ASightAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus)
{
  //Retrieving Perceived Actors
  TArray<AActor*> PerceivedActors;
  PerceptionComponent->GetPerceivedActors(TSubclassOf<UAISense_Sight>(), PerceivedActors);

  //Calculating the Number of Perceived Actors and if the current target Left or Entered the field of view.
  bool isEntered = PerceivedActors.Contains(Actor);
  int NumberObjectSeen = PerceivedActors.Num();

 //Formatting the string and printing it
 FString text = FString(Actor->GetName() + " has just " + (isEntered ? "Entered" : "Left") + " the field of view. Now " + FString::FromInt(NumberObjectSeen) + " objects are visible.");
 if (GEngine) {
 GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Turquoise, text);
 }
 UE_LOG(LogTemp, Warning, TEXT("%s"), *text);

}

一次又一次,我知道“1 objects”是不正确的,但修正复数(尽管可能)超出了本书的范围;让我们保持简单。

测试所有这些

现在,你应该已经实现了一个带有感知系统的 AI 控制器(无论是蓝图还是 C++——这并不重要,它们应该表现相同)。

通过按Alt + 拖动将玩家拖入关卡中创建另一个ThirdPersonCharacter(如果你想在上一章中创建的 AI 中使用,可以这样做):

详细信息面板中,我们让它由我们的 AI 控制器控制,而不是玩家(这个流程现在应该对你来说很容易):

或者,如果你使用 C++设置,选择以下设置:

在按下播放之前,创建一些可以被检测到的其他对象会很好。我们知道所有的 Pawns 都可以被检测到(除非被禁用),所以让我们尝试一个不是 Pawn 的对象——也许是一个移动平台。因此,如果我们想检测它,我们需要使用AIPerceptionStimuliSourceComponent

首先,让我们创建一个浮动平台(可以被我们的角色轻易推动)。如果你在ThirdPersonCharacter Example的默认关卡中,你可以通过Alt + 拖动复制以下截图中的大网格,否则,如果你使用自定义关卡,一个可以被压扁的立方体将工作得很好):

到目前为止,它太大,所以让我们将其缩小到(1, 1, 0.5)。此外,为了保持一致,你可以将其移动到(-500, 310, 190)。最后,我们需要将移动性更改为可移动,因为它需要移动:

接下来,我们希望能够推动这样的平台,因此我们需要启用物理模拟。为了保持我们的角色可以推动它,让我们给它一个质量为100 Kg(我知道,这看起来很多,但考虑到摩擦很小,并且平台可以漂浮,这是正确的数量)。此外,我们不希望平台旋转,因此我们需要在约束中阻止所有三个旋转轴。如果我们想让平台漂浮,也是同样的道理——如果我们锁定 z 轴,平台只能沿着XY 平面移动,没有旋转。这将确保平台易于推动。这就是物理部分应该看起来像的:

最后,我们需要添加一个AIPerceptionStimuliSourceComponent,从 Actor 名称附近的添加组件绿色按钮:

一旦添加了组件,我们就可以从前面的菜单中选择它。结果,详细信息面板将允许我们更改AIPerceptionStimuliSourceComponent设置。特别是,我们想要添加视觉感知,并自动将组件注册为源。这就是我们应该如何设置它:

作为可选步骤,你可以将其转换为蓝图,以便可以重用,并可能赋予一个更有意义的名称。此外,如果你想让多个对象被视觉感知系统跟踪,你可以复制它几次。

最后,你可以点击播放并测试我们迄今为止所取得的成果。如果你通过了我们的AI 控制角色,你将在屏幕顶部收到通知。如果我们推动平台进入或离开 AI 的视野,我们会得到相同的结果。以下截图显示了 C++实现,但它与蓝图实现非常相似(只是打印的颜色不同):

此外,作为预期,你可以使用可视化调试器查看 AI 的视野,我们将在第十三章中探讨,即AI 调试方法 - 游戏调试器。以下截图是我们创建的 AI 角色的视野参考。关于如何显示它以及理解所有这些信息的详细说明,请耐心等待第十三章的介绍,即AI 调试方法 - 游戏调试器

是时候给自己鼓掌了,因为这可能看起来你只做了一点点,但实际上,你成功地了解了一个复杂的系统。此外,如果你尝试了一种方法(蓝图或 C++),如果你想掌握蓝图和 C++中的系统,尝试另一种方法。

摘要

难道这不是一个感知到的信息量很大吗?

我们首先理解了内置感知系统中的不同组件如何在 Unreal 的 AI 框架内工作。从那里,我们探索了如何实际使用这些组件(无论是在 C++ 中还是在蓝图),并学习了如何正确配置它们。

我们通过一个设置 视觉 感知系统的实际例子来总结,这次我们既在蓝图(Blueprint)中,也在 C++ 中实现了这一点。

在下一章中,我们将看到我们如何模拟大型 人群

第六章:扩展行为树

赋予树更多的叶子和分支将使其变得不可阻挡

在本章中,我们将了解如何通过实现我们自定义的 任务装饰器服务 来扩展 行为树

由于在第八章、第九章和第十章中,我们将从头开始创建一个具体的 行为树 示例,并创建自定义的 任务装饰器服务,您可以将本章视为对这些章节的快速理论介绍,以便为您扩展 行为树 提供一个基础。因此,本章可能会非常流畅且重复,但它将向您介绍一个伟大的工具,我们将在本书的后续部分以更轻松的方式对其进行完善。

在本章中,我们将涵盖以下主题:

  • 如何在 BlueprintC++ 中创建一个 任务,使我们的 AI 代理能够执行自定义动作。

  • 如何在 BlueprintC++ 中创建一个 装饰器,以创建我们可以在 行为树 的某些子分支中输入的特定条件

  • 如何在 BlueprintC++ 中创建一个 服务,以持续更新 黑板 中用于 行为树 的数据

  • 如何创建 复合节点新类型的节点 或甚至 新树

那么,让我们深入探讨吧!

行为树的快速回顾

这里有一个关于 行为树 的快速回顾,以帮助您巩固记忆。

行为树 是一种决策结构,它使用 黑板 作为其内存。特别是,流程从称为 的特殊节点开始,一直到底部的叶子节点,这些叶子节点被称为 任务任务 是 AI 可以采取/执行的单一动作。

然后,所有非叶子节点(或根节点)都是 复合 节点。复合节点选择执行哪个子节点。两个主要的 复合 节点是 序列(它试图按顺序执行其子节点的所有序列,如果它们成功,则返回成功,否则返回失败)和 选择器(它尝试每个子节点,直到找到一个成功的,并报告成功,或者所有子节点都失败,并报告失败)。

复合任务 节点都可以使用 装饰器(它施加必须为真的条件,以便你可以选择该节点)或顶部的 服务(一段持续运行的代码,例如用于设置黑板值)。

如果您仍有疑问,请复习 第二章,行为树和黑板

创建一个任务

深入探讨我们在第二章“行为树和黑板”中讨论的概念,任务是我们的人工智能代理可以执行的单个动作。一些例子包括走到特定位置、执行/运行 EQS、定位某物、追逐玩家等。所有这些动作都可以失败或成功。然后,任务的最终结果会通过行为树返回,遵循我们看到的选择器和序列的规则。

任务不一定必须在帧中执行,但它可以无限期地扩展。实际上,任务只有在报告了失败成功后才会完成。然而,它们可以被外部节点(如装饰器)中断/中止。

当您创建任务时,无论这是在蓝图还是 C++中完成,您都需要覆盖一些函数。由于蓝图更容易,并且与我们在 C++中使用的概念相同,我们将首先查看蓝图中的系统是如何工作的。

创建蓝图任务

要创建一个蓝图任务,我们有几种选择可供选择。最简单的一种是在行为树编辑器中,我们在顶部栏中按下“新建任务”按钮,如下面的截图所示:

截图

然而,您需要手动重命名文件并将其放置在您希望它所在的文件夹中。

创建任务的另一种方法是创建一个新的蓝图,它继承自BTTask_BlueprintBase,如下面的截图所示:

截图

习惯上,在任务前加上“BBTask_”(代表行为树任务)。例如,我们可以将我们的任务命名为BTTask_BPMyFirstTask

截图

一旦创建了蓝图任务,我们可以覆盖三种类型函数:

截图

  • 接收执行:当任务开始时调用此功能,在这里您应该实现您任务的全部初始化。

  • 接收时钟:每次任务时钟滴答时都会调用此功能,因此您可以使用它来持续做某事。然而,由于可能有许多代理执行许多行为树,建议尽可能保持此时钟函数尽可能短,或者根本不实现它(出于性能原因),并使用计时器或委托来处理任务。

  • 接收中止:每次任务执行时都会调用此功能,但行为树请求中止它。您需要使用此功能来清理您的任务(例如,恢复一些黑板值)。

在蓝图(Blueprint)中,这三个功能以两种形式存在,即AI非 AI,也被称为是通用的(例如接收执行接收执行 AI)。它们之间没有太大的区别。如果只实现了一个(建议实现 AI 版本以保持项目一致性),那么调用的是这个功能。否则,最方便的版本将被调用,这意味着当 Pawn 被AI 控制器控制时,将调用 AI 版本,而在所有其他情况下将调用非 AI版本。当然,在大多数情况下,行为树都是在AI 控制器上运行的,所以非 AI版本是为非常特定且较少见的情况准备的。

到目前为止,系统没有方法理解一个任务何时完成执行或完成清理操作。因此,你需要调用两个函数:

  • 完成执行:这将表明任务已完成其执行。它有一个布尔参数来指示任务是否成功true值)或失败false值)。

  • 完成中止:这将表明任务已完成中止。它没有参数。

请注意,如果你不调用这两个函数,任务将永远挂在那里,这不是期望的行为。虽然建议在接收中止事件的末尾调用完成中止函数,但在某些情况下你可能需要多帧来清理。在这种情况下,你可以在其他地方(例如在委托中)调用完成中止

完成执行一个任务还有其他方法,例如使用 AI 消息,但在这本书中我们不会涉及这些内容。

创建任务你需要知道的所有内容。你只需创建你想要的图,记得在完成(无论是成功还是失败)时调用完成执行节点。我们将在接下来的三个章节中查看创建新任务的实例。

在 C++中创建任务

创建 C++中任务的概念与蓝图中的对应概念相同。

首先,要创建一个新的 C++任务,我们需要创建一个继承自BTTaskNode的 C++类,如下面的截图所示:

就像蓝图中的任务一样,惯例是在任务前加上"BTTask_"(行为树任务)。因此,我们可以将我们的任务命名为"BTTask_MyFirstTask":

一旦创建了任务,你需要重写一些函数,这些函数的功能与蓝图中的非常相似。然而,也有一些区别。

主要区别之一是如何报告任务已完成其执行(或已完成取消)。对于这些情况,有一个特殊的枚举结构称为EBTNodeResult。它需要由一个函数返回,这样行为树才能“知道”是否需要继续调用任务。这个结构可以有四个值:

  • 成功:任务以成功结束

  • 失败:任务以失败结束

  • 已取消:任务已取消

  • 进行中:任务尚未完成

另一个区别在于,蓝图接收执行的双胞胎必须完成,因此它需要返回一个EBTNodeResult结构来通信并声明任务是否已完成或是否需要多个帧。如果是这样,那么将调用其他函数,正如我们将看到的。

此外,在 C++中,还有一些其他特殊的概念和结构,你可以在蓝图中使用,而在蓝图中不能使用。例如,你可以访问NodeMemory,它为已执行的任务保留特定的内存。为了正确使用这个结构,请查看引擎源代码,特别是本节末尾建议的文件。

最后一个区别是,没有AI非 AI(通用*)版本的函数。你必须自己判断是否有 AI 控制器以及要做什么(如果你做了什么)。

函数如下(这直接取自引擎的源代码,其中两个最重要的函数用粗体表示):


   /** starts this task, should return Succeeded, Failed or InProgress
   * (use FinishLatentTask() when returning InProgress)
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
 virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory);

protected:
  /** aborts this task, should return Aborted or InProgress
   * (use FinishLatentAbort() when returning InProgress)
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
 virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory);

public:
#if WITH_EDITOR
  virtual FName GetNodeIconName() const override;
#endif // WITH_EDITOR
  virtual void OnGameplayTaskDeactivated(UGameplayTask& Task) override;

  /** message observer's hook */
  void ReceivedMessage(UBrainComponent* BrainComp, const FAIMessage& Message);

  /** wrapper for node instancing: ExecuteTask */
  EBTNodeResult::Type WrappedExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const;

  /** wrapper for node instancing: AbortTask */
  EBTNodeResult::Type WrappedAbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const;

  /** wrapper for node instancing: TickTask */
  void WrappedTickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) const;

  /** wrapper for node instancing: OnTaskFinished */
  void WrappedOnTaskFinished(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult) const;

  /** helper function: finish latent executing */
  void FinishLatentTask(UBehaviorTreeComponent& OwnerComp, EBTNodeResult::Type TaskResult) const;

  /** helper function: finishes latent aborting */
  void FinishLatentAbort(UBehaviorTreeComponent& OwnerComp) const;

  /** @return true if task search should be discarded when this task is selected to execute but is already running */
  bool ShouldIgnoreRestartSelf() const;

  /** service nodes */
  UPROPERTY()
  TArray<UBTService*> Services;

protected:

  /** if set, task search will be discarded when this task is selected to execute but is already running */
  UPROPERTY(EditAnywhere, Category=Task)
  uint32 bIgnoreRestartSelf : 1;

  /** if set, TickTask will be called */
  uint32 bNotifyTick : 1;

  /** if set, OnTaskFinished will be called */
  uint32 bNotifyTaskFinished : 1;

  /** ticks this task 
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds);

  /** message handler, default implementation will finish latent execution/abortion
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void OnMessage(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, FName Message, int32 RequestID, bool bSuccess);

  /** called when task execution is finished
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void OnTaskFinished(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult);

  /** register message observer */
  void WaitForMessage(UBehaviorTreeComponent& OwnerComp, FName MessageType) const;
  void WaitForMessage(UBehaviorTreeComponent& OwnerComp, FName MessageType, int32 RequestID) const;

  /** unregister message observers */
  void StopWaitingForMessages(UBehaviorTreeComponent& OwnerComp) const;

如你所见,代码量相当大,一开始可能会有些混乱。然而,如果你已经很好地理解了蓝图,那么理解 C++函数应该会容易得多。例如,ExecuteTask()函数开始执行任务,但如果它返回任务仍在进行中,则不会完成它。

这里是来自引擎源代码的注释,可能有助于澄清这一点:

/** 
 * Task are leaf nodes of behavior tree, which perform actual actions
 *
 * Because some of them can be instanced for specific AI, following virtual functions are not marked as const:
 * - ExecuteTask
 * - AbortTask
 * - TickTask
 * - OnMessage
 *
 * If your node is not being instanced (default behavior), DO NOT change any properties of object within those functions!
 * Template nodes are shared across all behavior tree components using the same tree asset and must store
 * their runtime properties in provided NodeMemory block (allocation size determined by GetInstanceMemorySize() )
 *
 */

我知道的两个最好的方法是更好地了解如何创建 C++任务,要么自己创建一个,要么阅读其他任务的源代码。例如,你可以阅读引擎源代码中的BTTask_MoveTo.cpp文件,以了解如何创建一个完整的 C++任务的示例。不要气馁,因为使用 C++很酷!

在任何情况下,我们将在接下来的三个章节中从头开始创建一个 C++任务的过程。

创建一个装饰器

从第二章,行为树和黑板中回忆起,装饰器是一个条件节点(也可以看作是一个门),它控制着它所附加的子分支的执行流程(如果执行首先进入子分支的话)。

与我们如何扩展/创建一个任务类似,我们也可以扩展/创建一个装饰器。再次强调,我们首先将深入探讨如何在蓝图(Blueprint)中实现它,然后继续讨论如何在 C++中扩展它。

在蓝图(Blueprint)中创建一个装饰器

要创建一个与任务类似的蓝图装饰器,你可以按下行为树编辑器顶部栏中的“新建装饰器”按钮,如下面的截图所示:

图片

或者,你可以生成继承自BTDecorator_BlueprintBase蓝图类,如下面的截图所示:

图片

在任何情况下,命名约定是在装饰器前加上“BTDecorator_”(代表行为树装饰器)。例如,我们可以将我们的类命名为BTDecorator_BPMyFirstDecorator

图片

对于任务,所有可重写的函数都有两种风味:AI非 AI。概念完全相同。如果只实现其中之一(为了保持项目一致性,建议重写 AI 版本),则调用该函数。如果两者都实现了,当 Pawn 被 AI 控制器拥有时,调用AI,而在所有其他情况下调用非 AI函数。

这里是装饰器可以扩展的六个函数:

图片

  • 执行条件检查:这是最重要的函数,也是唯一可能需要重写的函数(如果你没有动态事物要处理)。它返回一个 bool 值,表示条件检查是否成功。

  • 接收执行开始:当底层节点(无论是组合节点还是任务)的执行开始时,会调用此函数。使用此函数来初始化装饰器。

  • 接收执行完成:当底层节点(无论是组合节点还是任务)的执行完成时,会调用此函数。使用此函数来清理装饰器。

  • 接收 tick:这是 tick 函数,以防你需要持续更新某些内容。从性能的角度来看,不建议用于重操作,但如果完全不使用它就更好(例如,使用计时器或委托)。

  • 接收观察者激活:正如其名所示,当观察者被激活时,会调用此函数。

  • 接收观察者禁用:正如其名所示,当观察者被禁用时,会调用此函数。

如您所见,装饰器相当简单(至少在蓝图(Blueprint)中是这样);主要来说,你只需要重写/实现一个名为Perform Condition Check的函数,该函数返回一个布尔值:

图片

在任何情况下,我们将在接下来的三个章节中查看从零开始创建蓝图装饰器的具体示例。

在 C++中创建一个装饰器

与我们在 C++ 中扩展 Task 的方式非常相似,你还可以扩展一个 Decorator in C++。要继承的基类是 BTDecorator,如下面的截图所示:

习惯上,是在 Decorator 前缀为 "BTDecorator_"(Behavior Tree Decorator)。我们的 Decorator 的一个可能名称可以是 "BTDecorator_MyFirstDecorator":

直接进入 C++,这些是可重写的函数,如从引擎源代码中获取(有很多):


   /** wrapper for node instancing: CalculateRawConditionValue */
  bool WrappedCanExecute(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const;

  /** wrapper for node instancing: OnNodeActivation */
  void WrappedOnNodeActivation(FBehaviorTreeSearchData& SearchData) const;

  /** wrapper for node instancing: OnNodeDeactivation */
  void WrappedOnNodeDeactivation(FBehaviorTreeSearchData& SearchData, EBTNodeResult::Type NodeResult) const;

  /** wrapper for node instancing: OnNodeProcessed */
  void WrappedOnNodeProcessed(FBehaviorTreeSearchData& SearchData, EBTNodeResult::Type& NodeResult) const;

  /** @return flow controller's abort mode */
  EBTFlowAbortMode::Type GetFlowAbortMode() const;

  /** @return true if condition should be inversed */
  bool IsInversed() const;

  virtual FString GetStaticDescription() const override;

  /** modify current flow abort mode, so it can be used with parent composite */
  void UpdateFlowAbortMode();

  /** @return true if current abort mode can be used with parent composite */
  bool IsFlowAbortModeValid() const;

protected:

  /** if set, FlowAbortMode can be set to None */
  uint32 bAllowAbortNone : 1;

  /** if set, FlowAbortMode can be set to LowerPriority and Both */
  uint32 bAllowAbortLowerPri : 1;

  /** if set, FlowAbortMode can be set to Self and Both */
  uint32 bAllowAbortChildNodes : 1;

  /** if set, OnNodeActivation will be used */
  uint32 bNotifyActivation : 1;

  /** if set, OnNodeDeactivation will be used */
  uint32 bNotifyDeactivation : 1;

  /** if set, OnNodeProcessed will be used */
  uint32 bNotifyProcessed : 1;

  /** if set, static description will include default description of inversed condition */
  uint32 bShowInverseConditionDesc : 1;

private:
  /** if set, condition check result will be inversed */
  UPROPERTY(Category = Condition, EditAnywhere)
  uint32 bInverseCondition : 1;

protected:
  /** flow controller settings */
  UPROPERTY(Category=FlowControl, EditAnywhere)
  TEnumAsByte<EBTFlowAbortMode::Type> FlowAbortMode;

  void SetIsInversed(bool bShouldBeInversed);

  /** called when underlying node is activated
    * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void OnNodeActivation(FBehaviorTreeSearchData& SearchData);

  /** called when underlying node has finished
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void OnNodeDeactivation(FBehaviorTreeSearchData& SearchData, EBTNodeResult::Type NodeResult);

  /** called when underlying node was processed (deactivated or failed to activate)
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void OnNodeProcessed(FBehaviorTreeSearchData& SearchData, EBTNodeResult::Type& NodeResult);

  /** calculates raw, core value of decorator's condition. Should not include calling IsInversed */
  virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const;

  /** more "flow aware" version of calling RequestExecution(this) on owning behavior tree component
   * should be used in external events that may change result of CalculateRawConditionValue */
  void ConditionalFlowAbort(UBehaviorTreeComponent& OwnerComp, EBTDecoratorAbortRequest RequestMode) const;

此外,在引擎源代码中,我们可以找到以下注释,它解释了一些实现选择:

/** 
 * Decorators are supporting nodes placed on parent-child connection, that receive notification about execution flow and can be ticked
 *
 * Because some of them can be instanced for specific AI, following virtual functions are not marked as const:
 *  - OnNodeActivation
 *  - OnNodeDeactivation
 *  - OnNodeProcessed
 *  - OnBecomeRelevant (from UBTAuxiliaryNode)
 *  - OnCeaseRelevant (from UBTAuxiliaryNode)
 *  - TickNode (from UBTAuxiliaryNode)
 *
 * If your node is not being instanced (default behavior), DO NOT change any properties of object within those functions!
 * Template nodes are shared across all behavior tree components using the same tree asset and must store
 * their runtime properties in provided NodeMemory block (allocation size determined by GetInstanceMemorySize() )
 *
 */

不幸的是,我们没有时间详细讲解它们,但大多数都非常直观,所以你不应该很难理解它们的含义。无论如何,我们将在接下来的三个章节中查看如何从头创建一个 C++ Decorator 的具体示例(我们将使用许多这些函数)。

创建服务

回顾 第二章,行为树和黑板,一个 Service 是一个节点,如果附加到子分支的父节点之一,则持续运行。此节点的主要用途是更新 Behavior TreeBlackboard 中的数据,并且它是你需要创建的节点之一,因为它们非常特定于你的游戏玩法。

与我们扩展任务和 Decorators 的方式类似,我们也可以 扩展/创建 Services。我们首先将介绍如何在蓝图中进行扩展实现,然后了解如何在 C++ 中实现。

在蓝图创建服务

就像你对 TasksDecorators 所做的那样,你可以通过按 Behavior Tree Editor 顶部的 New Service 按钮创建一个新的 Blueprint Service,如下面的截图所示:

或者,你可以生成继承自 BTService_BlueprintBaseBlueprint 类,如下面的截图所示:

在任何情况下,命名约定是在 Service 前缀为 "BTService_"(代表 Behavior Tree Service)。例如,我们可以将我们的类命名为 BTService_BPMyFirstService

就像对 TasksDecorators 所做的那样,所有 可重写 的函数都有两种不同的版本:AI 和 非-AI (通用)。概念完全相同:如果只实现了一个(为了保持项目一致性,建议重写 AI 版本),则调用该函数。如果两者都实现了,当 Pawn 被 AI 控制器控制时调用 AI,否则调用通用函数。

这里是四个可重写的函数:

  • 接收激活:当服务变为活动状态时,会调用此函数。使用它来初始化服务。

  • 接收时钟:当服务时钟滴答时,会调用此函数。主要地,一个服务会持续进行某些操作(例如更新黑板变量),因此这是服务最重要的函数。从性能的角度来看,建议将其保持尽可能短。此外,回到行为树,可以调整服务滴答的频率(使用介于最小值和最大值之间的随机值)。然而,从理论上讲,实现应该不知道服务滴答的频率;它只需要提供一个"服务"。然后,服务的使用者,即行为树,将决定它希望这个服务多频繁。

  • 接收搜索开始:这是一个特殊案例,其中服务是活动的(因此你应该已经初始化了服务,但在理论上,服务不应该已经执行任何操作)。在搜索任务/节点之前,会调用此函数。实际上,行为树需要评估下一个要执行的任务节点。在这个过程中,行为树会检查可能任务节点上装饰器的条件。因此,在这个函数中,你可以在下一个任务节点被搜索之前调整值,并因此选择影响选择成为正确选择的东西。

  • 接收停用:当服务变为非活动状态时,会调用此函数。使用它来清理服务。

主要,你需要在Receive Tick函数中实现你的逻辑,以便你能够不断更新黑板上的信息。服务是行为树和游戏世界之间的一个层。

我们将在接下来的三章中实现一个蓝图服务,我们将面对一个更加实际和具体的例子。

在 C++中创建一个服务

与我们在 C++中扩展任务装饰器的方式非常相似,你还可以在 C++中扩展服务。要继承的基类是BTService,如下面的截图所示:

习惯上,在服务类名前加上"BTService_"(行为树服务)。我们类的可能名称可以是BTService_MyFirstService

一旦我们创建了C++中的服务,其余的工作就与在 C++中扩展/创建一个装饰器非常相似。这里有一些需要重写的函数(来自引擎源代码):

virtual FString GetStaticDescription() const override;

  void NotifyParentActivation(FBehaviorTreeSearchData& SearchData);

protected:

  // Gets the description of our tick interval
  FString GetStaticTickIntervalDescription() const;

  // Gets the description for our service
  virtual FString GetStaticServiceDescription() const;

  /** defines time span between subsequent ticks of the service */
  UPROPERTY(Category=Service, EditAnywhere, meta=(ClampMin="0.001"))
  float Interval;

  /** adds random range to service's Interval */
  UPROPERTY(Category=Service, EditAnywhere, meta=(ClampMin="0.0"))
  float RandomDeviation;

  /** call Tick event when task search enters this node (SearchStart will be called as well) */
  UPROPERTY(Category = Service, EditAnywhere, AdvancedDisplay)
  uint32 bCallTickOnSearchStart : 1;

  /** if set, next tick time will be always reset to service's interval when node is activated */
  UPROPERTY(Category = Service, EditAnywhere, AdvancedDisplay)
  uint32 bRestartTimerOnEachActivation : 1;

  /** if set, service will be notified about search entering underlying branch */
  uint32 bNotifyOnSearch : 1;

  /** update next tick interval
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

  /** called when search enters underlying branch
   * this function should be considered as const (don't modify state of object) if node is not instanced! */
  virtual void OnSearchStart(FBehaviorTreeSearchData& SearchData);

#if WITH_EDITOR
  virtual FName GetNodeIconName() const override;
#endif // WITH_EDITOR

  /** set next tick time */
  void ScheduleNextTick(uint8* NodeMemory);

这里有一个开头注释(总是来自引擎源代码),它解释了一些实现选择:

/** 
 * Behavior Tree service nodes is designed to perform "background" tasks that update AI's knowledge.
 *
 * Services are being executed when underlying branch of behavior tree becomes active,
 * but unlike tasks they don't return any results and can't directly affect execution flow.
 *
 * Usually they perform periodical checks (see TickNode) and often store results in blackboard.
 * If any decorator node below requires results of check beforehand, use OnSearchStart function.
 * Keep in mind that any checks performed there have to be instantaneous!
 * 
 * Other typical use case is creating a marker when specific branch is being executed
 * (see OnBecomeRelevant, OnCeaseRelevant), by setting a flag in blackboard.
 *
 * Because some of them can be instanced for specific AI, following virtual functions are not marked as const:
 * - OnBecomeRelevant (from UBTAuxiliaryNode)
 * - OnCeaseRelevant (from UBTAuxiliaryNode)
 * - TickNode (from UBTAuxiliaryNode)
 * - OnSearchStart
 *
 * If your node is not being instanced (default behavior), DO NOT change any properties of object within those functions!
 * Template nodes are shared across all behavior tree components using the same tree asset and must store
 * their runtime properties in provided NodeMemory block (allocation size determined by GetInstanceMemorySize() )
 */

不幸的是,我们无法详细说明每个函数,但它们都相当容易理解。无论如何,我们将在接下来的三章中进一步探讨 服务,当我们从头开始构建 行为树 以及实现 C++服务 时。

创建一个组合节点

在大多数这些情况下,你不需要扩展一个组合节点。通过“大多数这些情况”,我的意思是你可以创建非常复杂的 AI 行为树,它可以执行非常复杂的任务,而且你真的不需要扩展或创建一个组合节点,除非你真的需要。

事实上,组合节点会影响 行为树 的流程,包括要执行哪个节点、要检查哪些 装饰器 以及要激活哪个 服务。默认情况下,只有三个:选择器序列简单并行。这些将足以覆盖我们的大部分情况。

然而,如果你真的有特定的需求,那么虚幻引擎非常灵活,它允许你扩展一个 组合节点

首先,这在蓝图(Blueprint)中是不可能的,所以唯一能够扩展一个(或创建一个新的)组合节点的方法是通过 C++。

让我们看看一个具体的例子,说明为什么你可能想要创建一个新的 组合节点

原因:因为你可以实现一个在行为树中可能难以实现(或不可能实现)的流程。

示例:你可以用简单的并行节点模拟一个行为树,也可以不用它们。但这会非常复杂,而且不够整洁。因此,使用简单的并行节点可以大大简化工作流程(最初,在引擎的第一版中,没有简单的并行节点)。

具体示例:你想要根据某些权重随机选择在这个自定义组合节点下应该执行的任务。例如,权重可以通过一种特殊的 装饰器 来评估。因此,扩展 组合 可能需要在其他类型的节点上做额外的工作。

另一个具体示例:你可以创建一个组合节点,它不断随机选择一个子节点,直到达到阈值或其中一个子节点报告失败。

尽管这是一个非常有趣的话题,但不幸的是,它超出了本书的范围。因此,我们将限制自己只创建新的 任务装饰器服务

创建新的节点类型或新的树类型

理论上,你可以创建新的节点类型(对于 行为树 而言,这实际上并不需要,因为你将创建不同类型的结构)。实际上,你可以创建不再是 行为树 的不同树结构(例如,一个 对话树),这比为行为树创建另一个节点更有用。对话树 非常有趣,因为它们使用与 行为树 非常相似的结构,你可以使用相同的 编辑器(或者更好的是,它的一个稍微修改过的版本)来编辑树。

尽管我很想深入探讨这些主题,但本书的主要焦点是人工智能,因此讨论 对话树 超出了本书的范围。

摘要

在本章中,我们探讨了如何在 BlueprintC++扩展/创建 任务装饰器服务 的可能性。因此,这使我们能够为我们的 AI 代理创建复杂的行为,尤其是如果与我们在前几章中学到的内容相结合,如 导航EQS感知

在下一章中,我们将处理 人群,然后再跳转到在 第八章 设计行为树 – 第 I 部分、第九章 设计行为树 – 第 II 部分 和 第十章 设计行为树 – 第 III 部分 中创建具体示例。

第七章:群体

在本章中,我们将总结关于虚幻引擎中内置的人工智能的讨论。然后,我们将开始一段旅程,从头开始构建一个有趣的 AI 行为树,然后再检查虚幻引擎底下的巨大调试系统(包括 AI 调试)。

在本章中,我们将涵盖以下主题:

  • 游戏中的群体

  • RVO 避免

  • 群体管理者

这些主题可能很短,但这并不意味着它们不重要或容易实施。所以,让我们开始吧!

游戏中的群体

你是否曾在圣诞节时被困在繁忙的购物中心,或者在体育赛事中被一大群人包围?也许你曾在香气扑鼻的香料市场的摊位间购物?现在,想象一下这个环境中的所有人突然消失。这会改变氛围吗?我想会相当显著。无论环境中的群体大小如何,它们无疑都会增加整体氛围。

游戏中群体的一些优秀例子包括刺客信条系列*:

刺客信条:大革命中的群体

群体可以包括既可交互的非玩家角色(NPC),也可以是那些只是四处闲逛、忙着自己的事情的人。在某些情况下,比如在刺客信条中,群体中的成员将扮演重要的角色,例如要求你保护他们,为你提供保护,甚至要求你的资源(例如乞丐要钱)。在其他情况下,群体除了挡道外,对你的游戏玩法没有任何影响!

构建可信的群体

虽然群体是大量人群的集合,通常在特定地点有共同的目的,但它们的创建需要一些思考。这不仅仅是简单地堆砌一帮 NPC,给他们一些自主性(例如通过人工智能),然后按下播放。构建一个可信的群体需要我们考虑一些事情。

首先要考虑的是应该有哪些不同类型的人。回到我们关于香料市场的例子,你可能会找到店主、买家、乞丐、孩子等等。在游戏堆叠的以下屏幕截图中,没有大量的人群或人群:

堆叠中的小人群

然而,在末日危机中,如以下屏幕截图所示,有一大群人(在这种情况下是僵尸),都在试图攻击你:

末日危机中,一大群僵尸正在攻击玩家

下一个考虑因素更多的是美学方面。例如,根据其在空间和时间中的位置,运动类型、服装、年龄等也会有所不同。如果你在第三十字军东征期间攻击圣骑士,你不太可能(除非这是你追求的角度)看到穿着好像要去 20 世纪 30 年代鸡尾酒会的人物。

然后,我们有听觉方面的考虑。这个群体听起来是什么样子?他们是喧闹的还是低声细语?也许有自行车飞驰而过,孩子们在摇铃。所有这些方面都是创建逼真群体的重要元素。

最后,但同样重要的是,我们还有群体的移动。他们是怎样移动的?他们是遵循特定的路径,是被引导到特定的区域,还是可以自由地漫游到他们选择的任何地方?

动物

现在,并非所有群体都采取人类(或部分/类似人类)的形式。群体也可以是动物群体。正如我们之前讨论的,在开发动物群体时也需要考虑相同的因素。你还得注意动物之间的互动方式,因为这与人类的方式大不相同。例如,狼可能存在于不同大小的群体中,或者说“群体”中,比如说秃鹫群或鹿群,就像以下图片所示:

图片

(顶部):刺客信条:起源 中攻击秃鹫群体

(底部):孤岛惊魂 4 中的鹿群

群体移动

当涉及到群体功能的更多技术方面时,我们必须考虑群体与玩家之间交互的范围。例如,如果玩家在跑过他们时击中他们,群体成员会做出反应吗?如果会,他们是如何反应的?

在像《实况足球》、《FIFA》、《火箭联盟》等游戏里,群体在根据情况欢呼或嘘声之外,不会与玩家进行交互。当然,他们也会通过不可听见的呐喊/对话/欢呼来增加氛围:

图片

FIFA 2018 中的群体

通常,这种行为是通过使用巧妙动画的(顶点)材料来模拟一个玩家只能从远处看到且不与之互动的大群体来创造的。

群体动力学和创建逼真的行为

既然我们已经讨论了一些有助于创建逼真群体行为的特征,让我们从更技术性的角度来谈谈我们可以如何实现这一点。

基于流动

这些类型的方案关注的是群体而不是其组成部分。这意味着个体(在群体中)的独特行为是由于周围环境的输入而产生的。

实体基础

这类方法意味着这些人群中的角色没有任何自主性。这是因为他们的行为基于一系列预定义的规则,旨在模拟属于人群的个体中发生的社会/心理影响。这样,所有角色的移动都由这些规则决定。

基于智能体

这可能是处理人群最动态和灵活的方法。在基于智能体的方法中,角色是自主的,并且可以与个体互动。也就是说,这种类型的人群中的每个角色(在一定程度上)都有一定的智能,这使它们能够根据受其周围环境影响的规则集做出反应。

这是我们将在我们的 AI 系统中使用的方法,在本章中,我们将探讨处理人群的内置 Unreal 系统。

Unreal 中的人群

在 Unreal 中,处理大量人群可能会很具挑战性,尤其是如果你打算有一个复杂的系统。事实上,人群系统需要快速运行并使人群表现出真实的行为。

如果内置系统无法适当地扩展大量人群,那么你很可能是基于(几乎)你的整个游戏玩法来构建人群的。在这种情况下,你应该考虑实现自己的人群系统,即使是通过修改内置系统。然而,对于大多数游戏来说,内置系统已经足够了。

在 Unreal 中,有两个内置系统用于人群模拟/管理。具体如下:

  • UCharacterMovementComponent 的 RVO

  • Detour 人群系统

虽然可以同时运行它们,但这样做并不建议。所以,请确保使用最适合你需求的那个,或者创建自己的。

互易速度障碍(RVO)

互易速度障碍RVO)是一种算法,由三位研究人员 Jur van den Berg、Ming C. Lin 和 Dinesh Manocha 在 2008 年发表的论文"互易速度障碍用于实时多智能体导航"中发现。

RVO 算法是无路径感知的,这意味着它不知道智能体所遵循的路径,也不知道智能体正在导航的导航网格。此外,每个智能体都是独立于其他智能体进行导航,而不需要明确的通信。因此,RVO 即使对于大量智能体来说也非常快速,如果碰撞的数量有限,它还能提供足够的真实行为。

Unreal 中的 RVO

RVO 算法在 Unreal Engine 中的实现可以追溯到 Unreal Development Kit,或 UDK(UE3)。在 UE4 中,你可以在角色移动组件中找到实现的算法。

要在特定角色上激活 RVO,请打开其角色移动组件并导航到角色移动:避让部分。在这里,你可以打开算法并设置一些设置,如下面的截图所示:

图片

以下设置可供选择(您需要点击底部的箭头以展开所有设置):

  • 使用 RVO 避障:指定是否在此角色上使用 RVO 算法。

  • 避障考虑半径:RVO 算法只会考虑位于此半径内的障碍物。因此,如果在此半径内没有障碍物,RVO 不会改变角色的航线。另一方面,如果障碍物(例如其他角色)位于此半径内,RVO 将尝试避开它们。此参数非常重要,当使用 RVO 时,需要根据角色可能遇到的障碍物类型进行适当的调整。

  • 避障权重:这表示 RVO 在避障障碍物时需要干预的强度。实际上,算法会尝试在角色前进的方向和避开障碍物的方向之间取平均值。这是 RVO 算法的强度,决定了其行为。默认值是 0.5,在大多数情况下都适用。

  • 避障 UID:这是一个在 RVO 使用时自动生成的识别号(您无法设置它)。当您想要与避障管理器交互时,它很重要(有关更多信息,请参阅C++中的 RVO部分)。

  • 避障组别:这表示这个角色属于哪个避障组别。

  • 要避免的组别:这表示这个角色需要避开哪些避障组别。

  • 忽略的组别:这表示这个角色需要忽略哪些避障组,因此在执行 RVO 避障时不会考虑它们。

在多人游戏中,RVO 算法仅在服务器上运行。

这就足够使用该算法并在您的游戏中进行生产使用了。然而,如果您好奇并想深入了解,请继续阅读以下子节。

高级 RVO 设置

本节分为两个部分:我们可以在蓝图中进行什么操作,以及我们可以在 C++中进行什么操作。

蓝图中的 RVO

如果您有角色组件的引用,您会注意到您可以读取其所有变量(所有 Get 函数都在这里),但无法设置它们(没有 Set 函数),如下面的截图所示:

看起来您无法在游戏过程中随时开启或关闭 RVO,但这并不正确。实际上,您仍然可以在实时(游戏时间)中稍微改变 RVO 设置。特别是,您可以使用以下节点来改变 RVO 是否运行:

如果你查看 C++实现,其中角色需要注册到 RVO 管理器,那么为什么不能直接编辑布尔变量是显而易见的。实际上,这可能是 RVO 第一次被开启,所有初始化(例如,注册到 RVO 管理器)都需要处理。

此外,你还可以通过以下两个节点来更改角色所属的避障组以及哪些角色应该被避免:

图片

除了这三个函数之外,你在实时使用 RVO 方面相当有限,但 C++开启了新的可能性。

C++中的 RVO

当然,每次你进入 Unreal 的 C++领域时,你可以在做什么方面有非常广泛的可能性。在本节中,我们将探讨这些可能性中的一些。

首先,你将直接访问UAvoidanceManager,它存储了使用 RVO 的所有代理的数据。

角色移动组件的参考中,你可以检索到避障 UID,这可以用来查询避障管理器以获取FNavAvoidanceData结构,该结构包含角色的特定避障数据。除了可以访问结构中的数据外,你还可以用它进一步查询避障管理器以获取更多信息。

假设你想进行手动速度规划。你可以通过使用GetAvoidanceVelocity()函数来获取当前速度。

然而,了解所有可能性的最佳方式是查看源代码。特别是,你需要查看以下文件:Runtime/Engine/Classes/AI/Navigation/AvoidanceManager.h

RVO 观察

以下是我使用这种方法的一些观察结果:

  • 由于它是路径和 navmesh 无关的,代理可能被推离 navmesh。这意味着你需要考虑这种可能性(以及这种情况可能发生的频率,例如,如果你的地图有墙壁边界,那么角色不能被推离 navmesh)。

  • 如果你想让 RVO 在非角色演员上工作,那么你需要自己重新实现 RVO 算法(或者调整你的演员以使用角色移动组件)。

  • 如果在非常拥挤的空间中有许多角色(例如,非现实的行为,如横向滑动),RVO 可能工作得不好。

  • 如果避障考虑半径很高,并且角色需要在其他角色之间定位自己,那么位置可能对角色来说很困难(从而可能导致奇怪、古怪和不自然的行为)。

  • RVO 非常快,即使有多个角色在同一级别运行 RVO。事实上,如果没有障碍物,开销成本几乎可以忽略不计,因此通过使用适当的避障考虑半径,可以处理许多角色而不会出现任何问题。

  • 你可以实现 RVO 算法的鼻祖,即 VO,它是 RVO 但没有加权。如果性能真的是一个关注点,它甚至可以更快,但现实感会降低。你可以通过查看下一节的参考文献来获取更多关于此的信息。例如,通过修改引擎源代码中的避免管理器,你将能够轻松实现此算法(或任何你选择的其他算法)。

RVO 资源

以下是一些你可以查看的进一步 RVO 资源:

Detour Crowd

另一个内置的 Unreal 系统是 Detour Crowd。它基于 Recats Library,与 RVO 相比,它将考虑代理移动的 Navmesh。该系统已经几乎可以直接使用,但让我们深入了解它是如何工作的以及我们如何使用它。

Detour Crowd 系统的工作原理

在你的游戏世界中存在一个名为 DetourCrowdManager 的对象。它负责协调游戏中的群体。特别是,注册到 DetourCrowdManager 的代理将被考虑在内。DetourCrowdManager 接受任何实现了 ICrowdAgentInterface 的内容,该接口为 Manager 提供有关代理的数据。

实际上,在底层,Detour Crowd Manager 使用的是由 Mikko MononenRecast Library 中开发的 Detour Crowd 算法,Epic Games 为其需求进行了轻微修改。因此,Detour Crowd Component 为 Unreal FrameworkRecast Detour 提供了一个接口。你可以通过查看本节末尾的资源来获取更多关于此的信息。

有潜力通过实现 ICrowdAgentInterface 来创建代理。然而,Unreal 为你提供了一个名为 UCrowdFollowingComponent 的特殊组件,该组件实现了 ICrowdAgentInterface,以及其他功能。因此,任何具有 UCrowdFollowingComponent 的内容都有资格成为具有群体管理器的代理。实际上,该组件将自动将自己注册到群体管理器并激活 Detour Behaviour

为了使事情更简单,ADetourCrowdAIController 是一个预制的控制器,它将自动将 UCrowdFollowingComponent 添加到控制器本身。因此,这次,系统是由 Character Movement Component 的 AI 控制器直接触发的。

以下图表有助于解释这一点:

使用 Detour Crowd 系统

使用Detour Crowd系统的最简单方法是让您的 AI 控制器继承自 Detour Crowd AI Controller,如下面的截图所示:

图片

在 C++中,您需要继承自ADetourCrowdAIController(或向您的控制器添加UDetourCrowdFollowingComponent)。

一旦您为所有想要使用Detour Crowd的控制器完成设置,系统将基本上能够直接工作。

Detour Crowd 设置

如果您正在使用UCrowdFollowingComponent,则该组件将通过在Character Movement Component(如果可用)中使用避免设置来实现ICrowdAgentInterface

因此,我们在RVO部分看到的所有避免设置都将被Detour Crowd考虑。因此,以下截图中所突出显示的所有设置仍然适用于我们的 AI 角色:

图片

请注意,避免权重将不会被 Detour 系统考虑,因为它是一个RVO特定的参数。

因此,我们之前看到的所有蓝图函数(例如,更改组掩码)也都是有效的。

这些是针对每个角色的特定设置,但您可以调整整体的Detour Crowd 设置。为此,导航到项目设置,在引擎部分,您将找到一个名为Crowd Manager的条目,如下面的截图所示:

图片

从这里,我们可以访问所有的Detour Crowd Manager 设置。这些设置中的大多数来自原始的Recast Crowd 算法,而Unreal Detour Crowd Manager提供了一个接口,您可以在算法中设置这些变量。让我们从简单的开始:

  • 最大代理数: 这是 Crowd Manager 将处理的代理的最大数量。当然,数字越高,您可以一次放置的代理就越多,但这会影响性能。您应该仔细规划游戏所需的代理数量。此外,如果您查看源代码,这个数字将用于为 Crowd Manager 处理代理分配必要的内存。在内存较低的情况下,这一点值得注意。

  • 最大代理半径: 这是从 Crowd Manager 偏离的代理可以到达的最大尺寸。

  • 最大避免代理数: 这是 Detour 系统考虑的最大代理数量,也称为邻居。换句话说,这指定了应该考虑多少个邻居代理(最大)以进行避免行为。

  • 最大避免墙壁数:这是 Detour 系统应考虑的最大墙壁数(通常,障碍物段)。它的工作方式与最大避免智能体类似,但询问系统周围周围障碍物的多少段需要考虑。

  • Navmesh 检查间隔:这是实现智能体离开 navmesh 后应该检查和重新计算其位置多少秒的设置(系统将尝试将智能体推回 navmesh)。

  • 路径优化间隔:这是检查智能体应该多久尝试重新优化其路径的设置(以秒为单位)。

  • 分离方向限制:当另一个智能体在后面时,此值表示向左/右的夹紧分离力(forwarddirToNei的点积;因此,-1 的值表示此分离行为被禁用)。

  • 路径偏移半径乘数:当智能体接近角落转弯时,会对路径应用偏移。这个变量是这个偏移的乘数(这样您可以自由地增加或减少这个偏移)。

  • 解决碰撞:尽管 Detour 系统尽了最大努力,智能体仍然可能会发生碰撞。在这种情况下,碰撞应由 Detour 系统处理(此变量的值为 true)。在这种情况下,此变量设置为 false,智能体将使用 Character Movement 组件。这个组件将负责解决碰撞。

下一个屏幕截图中显示的避免配置参数是 Detour 人群算法中采样如何进行的核心:

避免配置是一个不同采样配置的数组,参数略有不同。默认情况下,有四个,分别对应不同的采样避免质量:低、中、好和高。

质量级别是在UCrowdFollowingComponent中的AvoidanceQuality变量中设置的,该变量使用ECrowdAvoidanceQuality枚举。如果您有对您的UCrowdFollowingComponent的引用,您可以使用SetCrowdAvoidanceQuality()函数。

返回到设置,如果您想添加或删除配置,您需要创建自己的UCrowdFollowingComponent版本(或者,您可以继承它并重写函数),这将考虑不同数量的配置。

然而,改变配置的数量意味着您的游戏/应用程序正在特别使用Detour 系统

在不改变配置数量的情况下,您可以更改这四个质量配置的设置。这些参数在以下屏幕截图中显示(这是第一个配置):

要完全理解这些设置,您应该了解算法是如何工作的,但让我们在没有这个的情况下尝试理解它。

算法中执行采样的部分首先在中心点(代理最初的位置,由于Velocity Bias参数的偏差,在速度方向上)周围创建一组环形(环形数量由Adaptive Rings参数指示)。这些环形中的每一个都通过Adaptive Division进行采样(分割)。然后,算法通过使用较小的一组环形,这些环形以上一迭代中最佳样本为中心,递归地细化搜索。算法重复此过程Adaptive Depth次。在每次迭代中,通过考虑以下因素并选择最佳样本,不同的参数确定权重(相对于其他因素的重要性):

  • 代理的方向是否与当前速度匹配?其权重为DesiredVelocityWeight

  • 代理是否向侧面移动?其权重为SideBiasWeight

  • 代理是否与任何已知障碍物发生碰撞?其权重为ImpactTimeWeight(如果代理在ImpactTimeRange秒内使用该速度发生碰撞,它将扫描一个范围,考虑代理的当前速度)。

以下图表应有助于你理解不同的参数:

调试 Detour Crowd Manager

Crowd Manager 与可视化记录器集成了,这意味着,通过一些工作,我们可以可视化地调试 Detour Crowd Manager。我们将在第十三章 Chapter 13,游戏调试器中更详细地探讨这一点,我们将了解更多关于可视化记录器的信息。

更多人群资源

如果你想扩展你对 Detour Crowd Algorithm 的了解或探索其他替代方案,以下是一些资源:

当然,你也可以继续自己探索!

摘要

在本章中,我们看到了 Unreal Engine 如何处理人群。特别是,我们看到了两个内置系统。第一个被称为互易速度障碍(RVO),它非常快,但不是很精确。第二个是Detour Crowd,它稍微昂贵一些,但更精确和逼真。

在下一章中,我们将继续学习如何从头开始实现自己的行为树

第二部分:设计和实现行为树

本节由三个章节组成,将带您踏上创建行为树整个过程的短暂旅程,从设计到实现。特别是,展示了一个具体的例子(以这种方式精心制作,以至于我们将需要使用前几章中几乎学到的所有技能),从而为您提供实践的机会。

本节将涵盖以下章节:

  • 第八章,设计行为树 - 第一部分

  • 第九章,设计行为树 - 第二部分

  • 第十章,设计行为树 - 第三部分

第八章:设计行为树 - 第一部分

本章(以及接下来的两章)将带您了解我们迄今为止所学到的更实际的方法。特别是,我们将关注如何实现一个 行为树,以便我们可以在游戏中追逐我们的角色。

事实上,我们将使用 第二章,行为树和黑板 的所有内容,以及一个 黑板 来执行这些动作,一个 NavMesh 来在环境中移动,以及一个 感知系统 来感知玩家。

在本章中,我们将涵盖以下主题:

  • 预期行为开始设计 行为树

  • 分析我们可能在 行为树 中需要的节点

  • 实现 自定义装饰器(在蓝图和 C++ 中)以检查布尔变量

  • 实现 自定义任务(在蓝图和 C++ 中)以找到角色周围的随机位置

  • 使用 导航系统查询 NavMesh 以找到随机位置

  • 实现 自定义 AI 控制器(在蓝图和 C++ 中)以使用 感知系统

  • 使用 感知系统来感知玩家

在接下来的两章中,我们将涵盖更多内容。

我们将在蓝图和 C++ 中实现所有内容,以便您对可以使用的内容有一个更广泛的概念。或者,如果您已经知道您想使用什么,您可以直接遵循两种实现中的任何一种。

如果您想跟上来,我会创建这个示例,从启动一个干净的项目开始(在我的情况下,我在 C++ 中这样做,但如果您只想跟随本章的蓝图部分,您可以使用蓝图模板),如下面的截图所示:

截图

Unreal 项目浏览器

该项目被称为 BehaviorTreeDesign,我正在使用 Third Person 模板。

说了这么多,让我们深入探讨吧!

预期行为

设计行为树的第一步是确定我们希望在角色中看到的 预期行为。这似乎是一个简单的阶段,但尝试考虑所有情况并不简单。然而,这将避免以后出现许多头痛问题。

在写下 预期行为 时,尽量具体。如果有什么不清楚的地方,尝试重新措辞。您使用的句子应该简短,并且总是添加信息。如果您有以 "在这种情况下…" 或 "如果…" 开头的句子,不要害怕,因为这仅仅意味着您正在考虑所有不同的可能性。一旦您写完,大声读出来,也许给朋友听,并问他/她是否清楚地理解其含义。

这是我尝试描述本章将要实现的行为:“智能体检查它是否能看到玩家。如果是真的,那么它将追逐玩家,直到他/她不再在视野中。如果玩家不在视野中,那么智能体将前往它最后一次看到玩家的位置(如果位置已知)。否则,智能体将在其周围选择一个随机位置并前往该位置。”

构建节点

在你编写了预期行为之后,下一步是分析它,以便你理解我们需要哪种类型的行为树节点。当然,我们总是可以在稍后阶段添加它们,但最好尽可能多地预测,以便你在创建行为树的过程中能够顺利地进行。让我们分解预期行为,以便我们尝试理解需要实现哪些节点。

已存在的节点

我们需要检查我们的行为中哪些部分已经存在于我们的项目中,以及它是否是内置功能,或者我们是否已经为该功能创建了节点(可能是为另一个 AI 行为树)。

“智能体检查它是否能看到玩家。如果是真的,那么它将追逐玩家,直到他/她不再在视野中。如果玩家不在视野中,那么智能体将前往它最后一次看到玩家的位置(如果位置已知)。否则,智能体将在其周围选择一个随机位置并前往该位置。”

特别值得一提的是,我们已经有了一个内置的 行为树任务,允许智能体追逐一个对象或到达一个位置。因此,预期行为中所有加粗的部分都已涵盖。

装饰器 - 检查变量

智能体检查它是否能看到玩家。 如果是真的,那么它将追逐玩家,直到他/她不再在视野中。如果玩家不在视野中,那么智能体将前往它最后一次看到玩家的位置(如果位置已知)。否则,智能体将在其周围选择一个随机位置并前往该位置。”

要执行此检查,我们需要决定智能体将如何“感知”玩家。在第五章中,我们看到了内置感知系统的工作原理,对于这样一个简单的任务,系统已经非常完美。因此,值得使用它。然而,我们需要将此信息转移到行为树中。因此,我们需要开始假设我们将如何实现整个 AI 行为。目前,让我们假设有关玩家是否在视野中的信息存储在一个布尔黑板变量中。因此,我们需要实现一个装饰器,使其能够检查这个布尔变量。

你还可以使用黑板装饰器(用于显示“基于黑板的条件”)来检查变量是否已分配,并使用它来确定玩家是否在视野中。然而,由于本章的主要目标是学习从实际角度从头开始构建行为树,因此创建一个额外的装饰器节点对你来说更有用,这样你就可以更熟悉创建装饰器的过程。

此外,在设计节点时,我们需要尽可能保持通用性,这样如果我们在另一个行为树中有类似的需求,我们可以重用我们创建的节点。因此,我们可以通过使用装饰器节点来检查布尔变量,我们将使用它来检查黑板中的变量是否告诉我们玩家是否在视野中。

一旦我们建立了这个,我们需要考虑我们将如何实现这个节点。在这个特定的情况下,它相当直接,但为了尽可能保持通用性,让我们考虑这个节点可能的其他用途。

例如,如果我们想检查变量是否为假呢?实际上,我们将需要这个功能(你将在本章后面理解为什么需要它)。幸运的是,虚幻引擎已经为我们提供了这个功能。事实上,在装饰器的详细面板中有一个方便的复选框,名为“逆条件”,正如其名所示,它反转条件的输出,使我们能够检查相反的情况:

图片

作为练习,忽略这个复选框,尝试实现你自己的条件反转版本。尽管它没有实际应用,并且这样做实际上是不好的做法,但它仍然是一个有用的练习,这样你可以理解如何向装饰器提供输入。在这个练习中,这个节点有两个输入:要检查的黑板键值(假设为布尔类型)和另一个布尔变量,用于确定检查的是变量的“真”值还是“假”值。

不再赘述,让我们继续进行这个节点的实际实现。像往常一样,我将在蓝图和 C++中同时进行。

检查变量蓝图实现

首先,让我们创建一个新的装饰器(回想一下,你可以从行为树编辑器或内容浏览器中创建它;前者更容易,但你需要有一个行为树打开。无论如何,将其命名为BTDecorator_CheckBooleanVariableBP(结尾的“BP”仅用于区分它与 C++版本的差异,因为你可能两者都会做。在实际项目中,你通常只有一个版本)。

如果你在没有向装饰器添加任何内容的情况下关闭编辑器(例如,为了重命名它),当你打开它时,你可能会看到如下屏幕:

图片

在这种情况下,只需点击“打开完整蓝图编辑器”进入蓝图编辑器。

正如我们之前所述,我们只需要一个类型为 Blackboard Key Selector 的单个变量作为输入,我们将它命名为 BoolVariableToCheckKey。它持有我们想要检查的黑板布尔变量的引用。此外,它需要是公共的(打开变量名称旁边的眼睛),这样它就可以在行为树编辑器中看到。它应该看起来是这样的:

图片

接下来,我们需要实现/重写 Perform Condition Check AI 函数,该函数可以在重写下拉菜单中找到,如下面的截图所示:

图片

一旦创建了这个函数,默认情况下它看起来是这样的:

图片

首先,我们需要检索我们的黑板键的布尔值,这可以通过使用“获取黑板值作为布尔值”节点来完成。然后,我们可以将此节点的“返回值”引脚连接到“返回节点”的“返回值”引脚。最终图形应该看起来是这样的:

图片

保存蓝图,装饰器将准备就绪。如果您愿意,可以将它放置在行为树中的某个位置,以查看输入是否显示正确。特别是,当放置在行为树中时,它看起来是这样的:

图片

最后,装饰器(在行为树编辑器中)的 详细信息面板 应该看起来如下:

图片

检查变量 C++ 实现

关于如何扩展装饰器的详细信息,您可以查看第六章,扩展行为树

首先,让我们创建一个新的 C++ 类,该类继承自 UBTDecorator。您需要搜索所有类并选择 BTDecorator,如下面的截图所示:

图片

然后,您可以重命名您的类 BTDecorator_CheckBoolVariable。如果您愿意,您还可以将文件放置在子文件夹中,例如 AI。以下是一个示例截图:

图片

点击创建类,您的 Decorator 类将被创建。

在您创建类之后,Unreal 将尝试编译您的代码。如果您没有正确设置项目中的公共依赖项(正如我们在第一章和第二章中学到的;特别是在第六章,扩展行为树),您应该收到如下类似的消息:

图片

然而,当您尝试从 Visual Studio 编译时,错误将如下所示:

图片

因此,您需要更改您的 .cs 文件(在我们的案例中,BehaviorTreeDesign.cs),并将“GameplayTasks”和“AIModule”作为公共依赖项添加,如下面的代码所示:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", **"GameplayTasks", "AIModule"** });

现在,你应该能够无任何问题地编译。

在头文件中,我们需要添加一个输入变量,一个引用布尔值的黑板键选择器,命名为BoolVariableToCheck。我们还需要通过使用UPROPERTY()宏将这个变量暴露给行为树编辑器,如下面的代码所示:

protected:
  UPROPERTY(EditAnywhere, Category = Blackboard)
  FBlackboardKeySelector BoolVariableToCheck;

然后,我们需要重写CalculateRawConditionValue()方法,这个方法是公开的,因此它的重写也需要是公开的。在头文件中插入以下代码行:

public:
  virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;

接下来,我们需要实现这个函数。

首先,我们需要检索BlackboardComponent,这允许我们从黑板键选择器中解析和获取值。幸运的是,我们可以从BeheviorTreeComponent(装饰器正在其上运行)中检索它,它作为名为OwnerComp的变量传递给节点。然而,要使用BlackboardComponent,我们需要在我们的.cpp文件中包含其定义,如下所示:

#include "BehaviorTree/BlackboardComponent.h"

如果由于某种原因,这个BlackboardComponen**t**无效(这可能会发生在你在项目中创建行为树但你没有黑板的情况下;否则这很难做到,因为行为树编辑器会自动选择一个黑板),我们只需return false

  //Get BlackboardComponent
  const UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
  if (BlackboardComp == NULL)
  {
    return false;
  }

然后,我们需要检索并返回从我们的黑板键选择器变量中作为布尔值的值。这是我们可以这样做的方式:

  //Perform Boolean Variable Check
  return BlackboardComp->GetValueAsBool(BoolVariableToCheck.SelectedKeyName);

整个函数应该看起来像这样:

#include "BTDecorator_CheckBoolVariable.h"
#include "BehaviorTree/BlackboardComponent.h"

bool UBTDecorator_CheckBoolVariable::CalculateRawConditionValue(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory) const
{
  //Get BlackboardComponent
  const UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
  if (BlackboardComp == NULL)
  {
    return false;
  }

  //Perform Boolean Variable Check
  return BlackboardComp->GetValueAsBool(BoolVariableToCheck.SelectedKeyName);
}

保存你的代码,装饰器就会准备好了。如果你愿意,你可以将它放在行为树中的某个地方,看看输入是否显示正确。这是它在树上的样子:

装饰器详细信息面板(在行为树编辑器内)应该看起来如下:

如你所注意到的,我们的装饰器的描述不会根据我们放入其中的变量而改变,也没有图标。如果你在一个大型项目中工作,注意这些细节可能会对你和你的团队有很大帮助。在这个小例子中,我将把它作为一个练习。你可以查阅第五章,代理意识,以获取更多关于如何做的信息。你也可以查阅源代码,特别是BTDecorator_TimeLimit,它实现了GetStaticDescription()DescribeRuntimeValues()GetNodeIconName()等函数。在本节中,我们将实现**GetStaticDescription()**函数,这样你就可以习惯于实现这类函数了。

如果你还没有阅读前面的提示框,请先阅读。现在,我们将实现我们的装饰器的GetStaticDescription()函数,以便我们可以看到为BoolVariableToCheck变量选择了哪个黑板键。

首先,我们需要在头文件中添加以下覆盖代码:

 virtual FString GetStaticDescription() const override;

然后,我们可以通过返回一个使用Printf()函数格式化的FString来实现它。通过使用***?***语句,我们可以确定键是否已设置,并显示正确的字符串值:

FString UBTDecorator_CheckBoolVariable::GetStaticDescription() const
{
  return FString::Printf(TEXT("%s: '%s'"), TEXT("Bool Variable to Check"), BoolVariableToCheck.IsSet() ? *BoolVariableToCheck.SelectedKeyName.ToString() : TEXT(""));
}

如果你编译并将装饰器添加到行为树中,它现在应该看起来像这样:

现在好多了!现在是时候实现一个行为树任务了。

任务 - 寻找随机位置

"代理检查它是否可以看到玩家。如果是,那么它会追逐玩家,直到他/她不再在视线中。如果玩家不在视线中,那么代理将前往它最后一次看到玩家的位置(如果位置已知)。否则,代理将在其周围选择一个随机位置并前往该位置。"

在我们的行为过程中,代理选择它周围的一个随机位置。这意味着我们需要创建一个任务,从代理的当前位置开始,选择一个它将前往的随机位置。此外,我们应该添加这个位置需要是代理可到达的。幸运的是,我们有一些预先制作的功能来查询导航网格并为我们选择一个随机位置

这也意味着我们需要假设我们有一个导航网格可供我们的代理使用。既然是这样,我们可以使用这个节点。然而,我们仍然需要创建一个可以在行为树中执行的任务,并且能够将这个值适当地存储在黑板中。

以一个通用节点的方式思考,我们希望添加一些额外的选项,以便我们可以自定义行为。例如,我们希望这个随机位置有多远?

我们有两个输入变量。第一个是一个黑板键选择器,它保存我们想要前往的随机位置(因为我们需要将其保存在黑板中)。第二个将是一个表示这个随机位置可以取到的最大半径的 float。

我们将再次在蓝图和 C++中执行此过程(这样你可以选择你最舒服的实现方式)。

寻找随机位置蓝图实现

创建一个蓝图行为树任务(阅读前面的章节了解如何做这个),并将其命名为BTT_FindRandomLocation

创建我们需要的两个变量,一个是名为“RandomDestination”的黑板键选择器类型,另一个是名为“Radius”的float类型。对于 float,设置一个不同于零的默认值,例如,3,000。最后,使它们两个都公开

让我们实现/覆盖以下截图所示的 Receive Execute AI 事件:

图片

从事件中,我们可以检索到控制器傀儡(代理)的位置,如图下截图所示:

图片

然后,我们可以使用 GetRandomReachablePointInRadius 节点在导航网格内生成一个随机的可到达位置。我们需要使用位置作为受控傀儡(代理)的原点,并将半径作为我们的*半径变量:

图片

查找随机位置蓝图

从 GetRandomReachablePointInRadius 节点的返回值中,我们创建一个分支节点。然而,生成随机位置的调用可能会失败。如果失败了,我们需要以失败(不是成功)结束任务。从分支真引脚,我们可以将随机位置设置到我们的目标键变量中,如图下截图所示:

图片

查找随机位置蓝图

然后,无论从分支(从设置黑板值为向量节点的末尾和分支False引脚),我们都需要完成执行任务。为此,我们可以将GetRandomReachablePointInRadius节点的返回值插入到完成执行成功引脚中:

图片

这就完成了我们的任务,我们现在可以保存它。

如果我们将此节点放置在行为树中,它将看起来像这样:

图片

详细信息面板将如下所示:

图片

如果你想,你可以阅读下一节来学习如何在 C++中实现这个任务,否则,你可以自由地跳过下一节。

查找随机位置 C++实现

在 C++中创建“查找随机位置”任务会比创建装饰器复杂一些,因为我们需要检索许多组件并检查它们是否有效。

首先,创建一个 C++ 行为树任务,通过选择 BTTaskNode 作为你想要扩展的类来继承自UBTTaskNode,如图下截图所示:

图片

然后,我们可以将其命名为BTTaskNode_FindRandomLocation并将其(就像我们对装饰器所做的那样)放置在一个文件夹中,例如AI

图片

首先,在头文件中,我们需要添加我们的两个变量。第一个是名为DestinationVector黑板键选择器,它将持有新计算出的目标引用。第二个是一个包含半径参数化(在其中我们将选择一个随机可到达点)的float。此外,它们都需要对行为树编辑器可访问;因此,我们需要使用UPROPERTY()宏来公开它们。我们需要使用以下代码行来设置这两个变量:

UPROPERTY(EditAnywhere, Category = Blackboard)
    FBlackboardKeySelector DestinationVector;

UPROPERTY(EditAnywhere, Category = Parameters)
    float Radius = 300.f;

和往常一样,在头文件中,我们需要重写 ExecuteTask() 方法,当这个任务需要执行时将会被调用:

 virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;

整个头文件应该看起来像这样:

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTaskNode_FindRandomLocation.generated.h"

/**
 * 
 */
UCLASS()
class BEHAVIORTREEDESIGN_API UBTTaskNode_FindRandomLocation : public UBTTaskNode
{
  GENERATED_BODY()

  UPROPERTY(EditAnywhere, Category = Blackboard)
  FBlackboardKeySelector DestinationVector;

  UPROPERTY(EditAnywhere, Category = Parameters)
  float Radius = 300.f;

  virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};

现在,当我们进入 .cpp 文件时,我们需要做一些准备工作,特别是在 include 语句中。实际上,我们将使用 黑板组件(就像我们在装饰器中做的那样)、导航系统AI 控制器 类。因此,我们需要包含所有这些,我们可以通过以下代码来完成:

#include "BTTaskNode_FindRandomLocation.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
#include "AIController.h"

因此,让我们定义 ExecuteTask() 函数:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{
 *//[REST OF THE CODE]*
}

然后,我们需要开始填充 ExecuteTask() 函数。我们首先需要做的是获取 黑板组件。如果这个组件不可用(如 装饰器 部分所述,这种情况很少发生,但仍然可能),我们需要返回任务失败,如下面的代码所示:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 //Get Blackboard Component
 UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
 if (BlackboardComp == NULL)
 {
 return EBTNodeResult::Failed;
 }

 *//[REST OF THE CODE]*

}

从黑板组件中,我们可以检索运行此实例的 行为树 的 AI 控制器的 受控角色。这可以通过使用几个 GET 函数来完成。然而,一旦再次,需要检查 角色 的有效性,如果它无效,那么任务需要返回失败:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 *//[PREVIOUS CODE]*

 //Get Controlled Pawn
 APawn* ControlledPawn = OwnerComp.GetAIOwner()->GetPawn();
 if (!ControlledPawn) {
 return EBTNodeResult::Failed;
 }

 *//[REST OF THE CODE]*

}

接下来,我们需要获取我们的导航系统。根据 Unreal 4.21,我们将使用 UNavigationSystemV1 类来完成这一任务。

从 Unreal 4.20 开始,导航系统已经被重构。因此,许多函数和类已经过时。如果你的引擎版本低于 4.20,这段代码将无法工作。在这种情况下,你需要使用 UNavigationSystem 类。由于这可能只对少数有特定需求的读者感兴趣,所以这本书没有涵盖这一点。

要获取 当前导航系统,我们需要通过使用名为 GetCurrent() 的特定函数(指导航系统)来指定我们想要从中检索这些数据的 世界。一旦我们获得了导航系统,我们想要检查其有效性,如果它无效,那么我们让任务失败:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 *//[PREVIOUS CODE]*

 //Get Navigation System
 UNavigationSystemV1* NavSys = UNavigationSystemV1::GetCurrent(GetWorld());
 if (!NavSys)
 {
 return EBTNodeResult::Failed;
 }

 *//[REST OF THE CODE]*

}

在我们能够在导航系统上执行查询之前,还有一步要走。我们需要创建一个名为 ResultFNavLocation 类型的变量,这是一个结构体,我们的 导航系统 将在其中填充查询的结果。在我们的案例中,我们只对位置感兴趣。因此,导航系统 能够执行查询:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 *//[PREVIOUS CODE]*

 //Prepare variables for Query
 FNavLocation Result;
 FVector Origin = ControlledPawn->GetActorLocation();

 *//[REST OF THE CODE]*

}

可以通过使用 GetRandomReachablePointInRadius() 函数来执行对查询的请求。它有三个强制参数,分别是查询需要执行的 起点半径 和返回结果的 结构。实际上,它的纯返回值是一个 布尔值,表示查询是否成功,我们可以用它来检查任务是否失败:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 *//[PREVIOUS CODE]*

 //Perform Query
 bool bSuccess = NavSys->GetRandomReachablePointInRadius(Origin, Radius, Result);
 if (!bSuccess) {
 return EBTNodeResult::Failed;
 }

 *//[REST OF THE CODE]*

}

如果我们能够得到一个随机点,我们需要在黑板上分配它,并返回任务已成功完成:

EBTNodeResult::Type UBTTaskNode_FindRandomLocation::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 *//[PREVIOUS CODE]*

 //Save Result and return success
 BlackboardComp->SetValueAsVector(DestinationVector.SelectedKeyName, Result.Location);
 return EBTNodeResult::Succeeded;
}

如果你现在尝试编译,你会得到一个错误。原因是我们在使用 Navigation System,但它没有被包含在我们的模块的公共依赖中。此外,如果你没有包含 AIModuleGameplayTasks,现在是添加它们的好时机,这样你就可以在没有错误的情况下编译代码。

打开 BehaviourTreeDesign.Build.cs 文件,并将 NavigationSystem 模块添加到公共依赖中,如下面的代码所示:

 PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "GameplayTasks", "AIModule", "NavigationSystem" });

现在,我们可以无任何问题地编译。

如果我们将此 Task 节点添加到我们的 Behavior Tree 中,它将看起来是这样的:

Details Panel 看起来如下:

正如我们之前讨论 Decorator 时所说的,实现描述节点的函数始终是一个好习惯,这样我们就可以更容易地使用它。我明白我们可能还没有准备好图标,但至少我们可以更改描述以显示我们分配了哪些变量。

要做到这一点,我们需要实现/覆盖 GetStaticDescription() 函数。在头文件中声明它,添加以下行:

 virtual FString GetStaticDescription() const override;

然后,在 .cpp 文件中,我们需要返回一个格式化后的 FString,以显示任务的变量。特别是,我们想显示 DestinationKeyRadius 的大小。我们可以使用 Printf() 函数轻松地格式化字符串,如下面的代码所示:

FString UBTTaskNode_FindRandomLocation::GetStaticDescription() const
{

  return FString::Printf(TEXT("%s: '%s'"), TEXT("DestinationKey"), DestinationVector.IsSet() ? *DestinationVector.SelectedKeyName.ToString() : TEXT(""))
      .Append(FString::Printf(TEXT("\n%s: '%s'"), TEXT("Radius"), *FString::SanitizeFloat(Radius)));
}

如果我们编译并再次将此 Task 添加到 Behavior Tree 中,它现在的样子应该是这样的:

这就完成了我们在 C++ 中的 Task 实现。

AI 控制器

Expected Behavior 中,我们得出结论,我们需要一个 Perception System 来检查代理是否能够看到 Player

再次强调,我们可以在蓝图或 C++ 中创建我们的 AI 控制器。

在蓝图实现 AI 控制器

首先,创建一个新的 AI 控制器,并将其命名为 BP_ChasingAIController,通过选择 AIController Class

在编辑器中,我们需要添加两个变量(这样我们下一章构建的服务就能检索到它们内部的值)。第一个变量是 LastKnownPlayerPosition,类型为 Vector,第二个变量是 CanSeePlayer,类型为 boolean,如下面的截图所示:

现在,我们需要添加感知组件。因此,从组件选项卡中,添加 AIPerception 系统,如下面的截图所示:

然后,在 Details 面板中,我们需要选择它的选项。特别是,我们需要设置 Sense of Sight

然后,在视觉配置设置中,检查所有的检测标志(正如我们在第五章中解释的,代理意识,我们需要检测玩家,默认情况下是中立的)。最后,设置应该看起来像这样:

图片

在详情面板中,我们需要通过点击旁边的+符号来生成On Target Perception Updated事件:

图片

现在,我们将事件中的Actor引脚从我们的玩家(例如,FirstPersonCharacterThirdPersonCharacter,取决于你选择的模板,或者如果你在项目中使用它,是你的Player 类),以检查感知的对象实际上是否是玩家:

图片

然后,我们断开刺激引脚以获取刺激位置,我们将其存储在LastKnownPlayerPosition变量中,以及Successfully Sensed,我们将其存储在CanSeePlayer变量中。当然,这些 Set 函数需要在转换之后放置。这是最终的代码:

图片

现在,AI 控制器已经准备好使用。

在 C++中实现 AI 控制器

首先,创建一个新的类,它继承自AIController

图片

然后,将其命名为ChasingAIController并将其放置在我们的AI文件夹中,如下面的截图所示:

图片

正如我们在关于感知的章节中解释的,我们首先需要包含与感知相关的类,以便能够使用它们。在头文件中添加以下#include语句:

#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISense_Sight.h"
#include "Perception/AISenseConfig_Sight.h"

接下来,我们需要添加我们的类构造函数的声明,因为我们将会使用一个来设置我们的控制器。在GENERATE_BODY()宏下面,添加以下代码:

  GENERATED_BODY()

 AChasingAIController();

我们需要跟踪我们将要添加到控制器中的PerceptionComponent。然而,AI 控制器基类已经有一个感知组件的引用,所以我们不需要声明它。你将在基类中找到这个非常相似的签名:

  UPROPERTY(VisibleDefaultsOnly, Category = AI)
  UAIPerceptionComponent* PerceptionComponent;

然而,我们需要有一个对我们将要创建的视觉配置的引用,因此我们需要这个变量:

UAISenseConfig_Sight* SightConfig;

由于在下一章中我们将创建一个服务来收集从这个控制器中的一些变量,我们需要创建两个公共变量。第一个变量是LastKnownPlayerPosition,类型为 Vector,第二个是CanSeePlayer,类型为布尔型。你可以在头文件中添加以下代码片段:

public:
  FVector LastKnownPlayerPosition;
  bool bCanSeePlayer;

最后,在我们的头文件中,我们需要为我们的感知系统添加一个代表,该代表将更新我们的变量。我们可以将此代表命名为OnTargetPerceptionUpdated()并使其受保护。它以AActor*FAIStimuli作为输入,如下面的代码所示:

protected:
  UFUNCTION()
  void OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus);

现在,我们需要在类的构造函数中创建感知组件。添加以下代码:

AChasingAIController::AChasingAIController() {

 //Creating the AI Perception Component
 PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("SightPerceptionComponent"));

  //*[REST OF THE CODE]*

}

然后,我们需要创建视觉感知,并将其配置为将所有DetectionByAffiliation设置为 true,如下面的代码所示:

AChasingAIController::AChasingAIController() {

 *//[PREVIOUS CODE]*

 //Create the Sight Sense and Configure it
 SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(FName("Sight Config"));
 SightConfig->DetectionByAffiliation.bDetectEnemies = true;
 SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
 SightConfig->DetectionByAffiliation.bDetectFriendlies = true;

  //*[REST OF THE CODE]*

}

现在我们有了PerceptionComponentSightConfig,我们需要将后者分配给前者:

AChasingAIController::AChasingAIController() {

 *//[PREVIOUS CODE]*

 //Assigning the Sight Sense to the AI Perception Component
 PerceptionComponent->ConfigureSense(*SightConfig);
 PerceptionComponent->SetDominantSense(SightConfig->GetSenseImplementation());

  //*[REST OF THE CODE]*

}

最后一步是将我们的委托函数绑定感知系统,如下面的代码所示:

AChasingAIController::AChasingAIController() {

 *//[PREVIOUS CODE]*

 //Binding the OnTargetPerceptionUpdate function
 PerceptionComponent->OnTargetPerceptionUpdated.AddDynamic(this, &AChasingAIController::OnTargetPerceptionUpdate);

}

现在,我们需要实现我们的OnTargetPerceptionUpdate()委托。首先,我们需要包含我们的玩家类的头文件。在这个示例中,我们有一个名为BehaviorTreeDesignCharacter的 C++类(蓝图ThirdPersonCharacter从这个类继承)。在我的情况下,我添加了以下#include语句(你可以包含你的玩家类中的一个):

#include "BehaviorTreeDesignCharacter.h"

特别是,我们需要检查传递给我们的 Actor(作为输入)是否真的是玩家类,我们可以通过强制类型转换(到我们包含的玩家类)来完成:

void AChasingAIController::OnTargetPerceptionUpdate(AActor * Actor, FAIStimulus Stimulus)
{
 if(Cast<ABehaviorTreeDesignCharacter>(Actor)){

 }
}

如果是这样,我们可以使用刺激输入来检索StimulusLocation,如果它WasSuccessfullySensed,并将其分配给我们的LastKnownPlayerPositionCanSeePlayer变量,如下面的代码所示:

void AChasingAIController::OnTargetPerceptionUpdate(AActor * Actor, FAIStimulus Stimulus)
{
  if(Cast<ABehaviorTreeDesignCharacter>(Actor)){
 LastKnownPlayerPosition = Stimulus.StimulusLocation;
 bCanSeePlayer = Stimulus.WasSuccessfullySensed();
  }
}

AI 控制器现在已准备好使用!

使用 AI 控制器

无论你使用的是蓝图还是 C++实现,你都需要将控制器分配给你的追逐代理。无论你是直接在蓝图中进行,还是在游戏的实例版本中,在 Pawn 设置下,你都需要更改 AI 控制器,使其为我们刚刚创建的那个。我们还需要确保 AI 能够自动控制这个 Pawn。

因此,你应该会有类似以下的内容:

现在,我们几乎已经拥有了构建追逐代理所需的所有组件。

摘要

在这一章中,我们开始深入了解如何创建行为树的示例,并使用了迄今为止遇到的所有系统。

尤其是我们可以看到如何写下预期行为,并从这里开始收集构建我们的 AI 所需的所有不同组件。我们看到了如何在 C++和蓝图中都这样做。我们创建的组件包括一个自定义装饰器,用于检查行为树中的布尔变量,一个自定义任务,通过使用导航系统来找到一个随机位置,以及一个自定义 AI 控制器,这样我们就可以使用感知系统来感知玩家

在下一章中,我们将继续这个示例,并构建我们需要的最后一个组件,以便我们可以更新追逐行为的变量。在下一章的结尾,你将准备好构建行为树。所以,让我们继续前进!

第九章:设计行为树 - 第二部分

本章是上一章的延续。特别是,在我们下一章构建最终的 Behavior Tree 之前,我们将构建最后缺失的拼图。

尤其是我们将涵盖以下主题:

  • 创建玩家角色,以及追逐代理

  • 在关卡内设置导航系统

  • 实现一个自定义服务(在蓝图和 C++中),以更新追逐行为所需的变量

我们将再次在蓝图和 C++中实现一切,以给你一个更广泛的想法,了解你可以使用什么。或者,如果你已经知道你想要使用什么,你只需遵循两种实现方法之一。

制作自定义服务是耗时最长的部分,因为我们将逐步进行。

让我们开始吧!

为测试 Behavior Tree 设置环境

在我们继续之前,让我们从编码中休息一下,创建我们需要测试 Behavior Tree 的环境。准备一个好的测试环境可以让你轻松地发现并修复错误。

在本节中,我们不会做任何花哨的事情,但我们将逐步查看测试我们的 AI 所需的内容。

创建玩家

首先,我们需要在关卡上有一个玩家,因为我们的 AI 代理将追逐玩家。此外,在本章中我们将编写的代码中,我们需要引用一个玩家类。

在这种情况下,我们已经在我们的项目中有了 ThirdPersonCharacter(如果你是从第三人称模板创建的项目)。右键单击它,选择创建子蓝图类,如下面的截图所示:

截图

然后,我们可以将其重命名为玩家

截图

双击玩家,在蓝图编辑器中打开它。在详细信息面板中,在 Pawn 选项卡下,我们需要将自动占据玩家更改为玩家 0,并将自动占据 AI 更改为禁用,如下面的截图所示:

截图

因此,我们将有一个专门用于玩家 Actor的类(它是一个蓝图类,这很重要)。一旦将其放置在地图上,它将被玩家占据(更准确地说,由玩家控制器 0占据)。

创建追逐代理

下一步是设置追逐代理。

我们在前一章中已经为它创建了一个控制器,既有蓝图也有 C++。然而,我们需要创建一个将被占据的实际 Pawn。我们可以用与创建玩家非常相似的方式来实现这一点。

创建 ThirdPersonCharacter 的另一个子蓝图,但这次将其重命名为 AI_ChasingAgent:

截图

双击以打开蓝图编辑器。正如我们在上一章中在使用 AI 控制器部分所预料的,在细节面板下,在 Pawn 选项卡中,我们需要将 Auto Possess Player 设置为 Disabled,Auto Possess AI 设置为 Placed in World or Spawned,并将 AI Controller Class 设置为 ChasingAIController(或者如果你更喜欢蓝图版本,则为BP_ChasingAIController),如下面的截图所示:

图片

由于我们将会有许多代理试图追逐玩家,如果他们使用当前的设置,他们可能会卡住。然而,在第七章[人群]中,我们探讨了我们可以用来处理这类情况的技术。特别是,如果我们只有少数几个代理,激活RVO Avoidance可能就足够了。因此,从组件面板中选择CharacterMovementComponent

图片

然后,在角色移动:避免选项卡中,我们只需勾选使用 RVOAvoidance。默认设置应该足够好,但如果你需要更多帮助,请随意根据你的需求调整(查看第七章,人群):

图片

保存AI_ChasingAgent。因此,我们的追逐代理已经准备好放置在地图中,一旦我们实现了行为树并启动它,它将开始追逐玩家。

准备级别

我们已经有了玩家追逐代理。然而,我们需要设置一个可以测试我们行为的级别。因此,我们可以复制(或者如果你更喜欢,可以直接使用)ThirdPersonExampleMap并将其重命名为更熟悉的名字(例如,TestingChasingBehavior)。

在这里,我将留给你的想象力,以便你可以为我们的角色构建一个不错的测试地图。一旦你完成了,请回到这里继续阅读。为了简单起见,我不会修改地图,但将描述应该采取的下一步。

第一步是擦除地图中可能存在的所有字符(例如,ThirdPersonCharacter),因为我们将会用我们的来替换它们。然后,我们将放置(通过从内容浏览器拖动到视口)一个,并且只有一个,玩家:

图片

然后,我们可以以与玩家相同的方式放置几个追逐代理:

图片

我们几乎完成了。最后一步是为该级别设置导航。实际上,我们的随机位置查找任务依赖于导航网格已为该级别设置。我们在第 XX 章详细介绍了导航系统,所以如果你需要进一步的帮助,请修改该章节。本节将仅描述如何快速设置我们级别的导航。

要构建导航系统,我们只需从模式面板中选择导航网格边界体积(通过选择所有类标签)并将其拖入地图中:

图片

然后,你需要将其扩展以覆盖整个地图。如果你按下P键,你将能够预览你的NavMesh,如下面的截图所示:

图片

现在,我们准备出发。让我们继续进行编码部分。

服务 - 更新追逐行为

在我们的预期行为中,我们没有描述任何类似服务的内容,这是可以的——这意味着我们本身不需要类似服务来处理我们的行为。然而,每个行为树必须以某种方式更新相关值。一种方法是通过使用服务。

更新特定值的服务的类型通常(在实现上)是特定的,并且不太可能重用,但有时它们是运行行为树所必需的。此外,因为我们已经查看了一个创建任务装饰器的实际示例,这是学习更多关于服务的好机会。

我们需要考虑包含在黑板中的变量,以及哪个需要更新。

我们需要分配的第一个变量是玩家。实际上,我们需要有一个对玩家 Pawn 的引用,以便当代理在视野中时它们可以追逐。然而,我们不需要每次服务更新时都更新这个值,只需在服务启动时更新。

值得注意的是,这个服务将被放置在树的开始处。每次行为树重新启动时,服务都会再次“重启”,更新玩家引用。这是有意为之的,因为如果玩家死亡,并且另一个 Pawn 生成,这个服务也会更新对新 Pawn 的引用。

然后,我们必须更新布尔变量,以确定玩家当前是否在视野中。由于这个变量将决定执行行为树中的哪个部分(在我们的情况下,装饰器如果条件不满足将会剪枝,我们将在本章后面看到),我们必须在服务的每个 tick 更新它。

最后要更新的变量是 Destination(在这种情况下,玩家不在视线中)。实际上,这个变量将包含玩家刚刚离开视线的 最后已知玩家位置。否则,该变量将包含我们在 Task 中分配的随机位置。因此,我们需要在服务的每个 tick 中检查是否更新这个变量(因为我们希望只有在玩家离开我们的代理视野时,才更新玩家最后看到的位置)。目前,玩家不再在视线中,所以我们只更新这个值一次,因为 最后已知玩家 位置不会改变,直到 Player 再次出现在视线中,行为树将保持它,直到不再需要,并用 随机位置 覆盖它。我们可以通过在服务中使用一个局部变量来实现这种行为,该变量跟踪布尔变量的最后一个值(如果玩家在视线中),如果它与服务的当前周期(tick)不同,则我们使用 最后已知玩家位置 更新 Destination 变量。

此外,值得注意的是,我们将从这个变量中获取代理控制器的值,这使得这个服务依赖于这个特定的控制器(这就是我之前说这些类型的服务不太可重用的原因)。

现在我们已经清楚地了解了我们的服务应该做什么,让我们来看看如何实现它(在蓝图和 C++中,这样你可以选择你更喜欢的方法)。

更新追逐行为蓝图实现

首先,我们需要创建服务并命名为 BTService_UpdateChasingBehavior。然后,我们需要添加一些变量。我们将第一个变量命名为 CanSeePlayerKey,其类型为 Blackboard Key Selector,它将保存 Blackboard 中确定 AI 是否可以当前看到玩家的布尔变量的引用。当然,这个变量需要是公共的,这样它就可以从行为树中设置。第二个变量,其类型始终为 Blackboard Key Selector,命名为 PlayerKey,是 Blackboard 中玩家 Pawn 的引用;这也必须是公共的。第三个是一个名为 LastKnownPositionKey 的公共 Blackboard Key Selector,但它将接收我们在上一节中讨论的 Destination 向量。最后一个变量是一个类型为布尔值的局部私有变量,命名为 LastCanSeePlayer,它存储了 CanSeePlayer 布尔变量的上一个状态(在上一个 tick 期间)。这样,就可以知道状态是否已更改,以及是否需要更新目的地。最后,这是我们的变量在编辑器中应该出现的方式:

下一步是 覆盖/创建 Receive Activation AI 事件,如下面的截图所示:

这个事件仅在服务被激活时触发,在我们的案例中,这将是每次行为树重新启动时。在这里,我们需要获取到Player的引用。我们可以通过使用Get All Actor of Class节点轻松实现这一点。我们需要提供类 player,这样我们就可以插入我们选择的Player类。在这种情况下,我们将使用本章开头创建的 Player 类:

图片

如果你想要使你的服务更加模块化,你可以将 Player 类作为一个变量传递,这样你就可以根据行为树来改变它。在 C++实现中,我们会这样做,主要是因为这样更容易引用蓝图类。

然后,我们假设游戏中只有一个玩家(否则,你应该有逻辑来找到正确的玩家去追逐;可能是最近的那个?)并从数组中获取它。最后,我们使用Set Blackboard Value as Object节点将其保存到黑板的Player Key 对象引用中。这是事件的最终图表:

图片

现在,我们需要覆盖/创建****Receive Tick AI事件,如下面的截图所示:

图片

我们可以做的第一件事是将所有者控制器转换为我们在前面创建的控制器类:

图片

你也可以使用ChasingAIController(用 C++编写的非蓝图版本)。然而,如果你这样做,你将无法访问它的变量。即使它们被声明为 public,如果没有在它们之前使用***UPROPERTY()***宏,它们对蓝图来说就是不可见的。所以,如果你想使用控制器的 C++版本,确保在每个变量之前添加UPROPERTY()宏(带有适当的参数),以便它们对蓝图也是可见的。

现在,如果转换成功,我们可以从BP_ChasingAIController中收集到CanSeePlayer变量的引用。然后,通过使用CanSeePlayerKey变量,我们可以使用Set Blackboard Value as Bool在黑板上设置其值。这是我们到目前为止的图表:

图片

接下来,我们需要比较这个值(当前的CanSeePlayer布尔值)与存储在LastCanSeePlayer变量中的值(存储上一次 Tick 的值)。我们可以通过使用Equal节点和Branch来实现这一点,如下面的截图所示:

图片

如果这两个值不同,那么我们需要从BP_ChasingAIController中检索LastKnownPlayerPosition并将其通过LastKnownPlayerPositionKey变量设置在黑板上:

图片

最后,无论我们是否更新了这个向量(在TrueFalse分支中),我们都需要使用当前值更新LastCanSeePlayer变量。这是图的最后一部分:

保存服务,我们终于准备好构建我们的行为树了!

如果您在行为树中添加这个服务,它看起来会是这样:

服务详细信息面板(在行为树编辑器中)应该如下所示:

在下一节中,我们将实现这个服务在 C++中,并将有许多事情需要考虑。当然,您也可以在 C++中重复这个过程来提高您的技能;否则,您可以跳到下一节,我们将构建我们的行为树。

更新追逐行为 C++实现

在本节中,我们将重新创建C++中的更新追逐行为服务*。

让我们从创建一个新的类开始,该类继承自BTService

我们将重命名我们的类BTService_UpdateChasing并将其放置在AI文件夹中,就像我们在前面的章节中对其他 AI 类所做的那样:

在代码创建之后,如果无法编译,请确保您遵循了前面的章节。实际上,我们已经将GameplayTasksAIModule添加到了我们项目的公共依赖中。为了您的方便,以下是我们在前面的章节中所做的工作:

您需要更改您的.cs文件(在我们的例子中,是BehaviorTreeDesign.cs*)并添加GameplayTasksAIModule作为公共依赖,如下面的代码所示:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", **"GameplayTasks", "AIModule"** });

现在,您应该能够编译而不会出现任何问题。

下一步是在头文件中为我们的服务添加一些变量。我们可以调用第一个 CanSeePlayerKey,其类型为黑板键选择器(Blackboard Key Selector),它将持有黑板上确定 AI 是否可以当前看到玩家的布尔变量的引用。当然,这个变量需要设置 UPROPERTY(),以便可以从行为树中设置。第二个变量,总是类型为黑板键选择器(Blackboard Key Selector),命名为 PlayerKey,是黑板上玩家 Pawn 的引用;这也需要 UPROPERTY() 宏。第三个是一个名为 LastKnownPositionKey 的另一个黑板键选择器,总是带有 UPROPERTY() 宏,但它将接收黑板中的目标向量,正如我们之前讨论的那样。最后一个变量是一个类型为布尔值的局部私有变量,命名为 "LastCanSeePlayer",它存储了 CanSeePlayer 布尔变量的上一个状态(在上一个 Tick 期间)。这样,就可以知道状态是否已更改,以及是否需要更新目标。

以下代码需要插入到头文件中:

 UPROPERTY(EditAnywhere, Category = Blackboard)
  FBlackboardKeySelector CanSeePlayerKey;

  UPROPERTY(EditAnywhere, Category = Blackboard)
  FBlackboardKeySelector PlayerKey;

  UPROPERTY(EditAnywhere, Category = Blackboard)
  FBlackboardKeySelector LastKnownPositionKey;

private:

  bool bLastCanSeePlayer;

现在,我们需要另一个变量——不是用于服务的逻辑,就像之前的案例一样,而是一个用于从行为树中选择玩家类(Player class)的变量。我们将这个变量命名为 PlayerClass,其类型为 TSubclassOf,这样我们就可以选择任何从 AActor 继承的类。当然,这个变量也需要有 UPROPERTY() 宏,以便它可以直接从行为树中发送:

我们将镜像服务的蓝图版本,其中我们找到该类的所有演员,并假设只有一个。在本章的结尾,将提出一种不同的方法。

  UPROPERTY(EditAnywhere, Category = PlayerClass)
  TSubclassOf<AActor> PlayerClass;

下一步是声明一些函数,以便我们可以覆盖/创建蓝图 Receive Activation AIReceive Tick AI 事件的 C++ 版本。这些分别称为 OnBecomingRelevant()TickNode(),覆盖它们的签名如下:

protected:

  virtual void OnBecomeRelevant(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;

  virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

最后,我们需要为我们的服务声明一个构造函数。您很快就会明白为什么:

 UBTService_UpdateChasing(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

现在,在 .cpp 文件中,我们需要实现这些函数。

让我们从构造函数开始。在这个函数中,我们需要为我们的服务初始化一些值;特别是,我们想要通知(通过将相应的变量设置为 true)我们想要使用 OnBecomeRelevant() 函数。即使这不是必需的,因为这个变量默认设置为 true,但在构造函数中明确设置这类变量的值是一种非常好的做法。既然我们在这里,关闭对 OnCeaseRelevant() 函数(OnBecomeRelevant() 的逆函数)的调用也是值得的。以下代码显示了包含我们需要设置的布尔变量名称的构造函数:

UBTService_UpdateChasing::UBTService_UpdateChasing(const FObjectInitializer& ObjectInitializer)
  : Super(ObjectInitializer)
{
 bNotifyBecomeRelevant = true;
 bNotifyCeaseRelevant = false;
}

下一个要实现的事件是OnBecomRelevant(),它仅在服务变得相关(被激活)时触发,在我们的案例中,这将是每次行为树重新启动时。在这里,我们需要获取玩家的引用,以便我们可以在黑板中存储它。首先,我们需要检索黑板组件:

void UBTService_UpdateChasing::OnBecomeRelevant(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

 //Get Blackboard Component
 UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
 if (BlackboardComp == NULL)
 {
 return;
 }

 *//[REST OF THE CODE]* 
}

然后,我们需要使用类似于蓝图节点GetAllActorsOfClass的方式来检索玩家。具体来说,我们将创建一个空的TArray<AActor*>并使用UGameplayStatics::GetAllActorsOfClass()函数绕过世界、玩家类和空数组。现在,这个函数将填充我们的数组:

void UBTService_UpdateChasing::OnBecomeRelevant(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{
  //Get Blackboard Component
  UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
  if (BlackboardComp == NULL)
  {
    return;
  }

 //Retrieve Player and Update the Blackboard
 TArray<AActor*> FoundActors;
 UGameplayStatics::GetAllActorsOfClass(GetWorld(), PlayerClass, FoundActors);

 *//[REST OF THE CODE]* 
}

接下来,我们假设游戏中只有一个玩家(否则,你需要找到正确的玩家来追逐;可能是最近的那个?)并检查数组的第一个元素是否有效,如果是,我们使用PlayerKey变量将其保存在黑板组件中。

这是执行此操作的代码:

void UBTService_UpdateChasing::OnBecomeRelevant(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{
  //Get Blackboard Component
  UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
  if (BlackboardComp == NULL)
  {
    return;
  }

  //Retrieve Player and Update the Blackboard
  TArray<AActor*> FoundActors;
  UGameplayStatics::GetAllActorsOfClass(GetWorld(), PlayerClass, FoundActors);
 if (FoundActors[0]) {
 BlackboardComp->SetValueAsObject(PlayerKey.SelectedKeyName, FoundActors[0]);
 }
}

再次,我们已经镜像了蓝图服务。

此外,在 C++中,我们可以进行额外的一步,如果Key已经设置(这是在蓝图中所不能做的),则避免再次设置Player。因此,我们可以添加这个if 语句

void UBTService_UpdateChasing::OnBecomeRelevant(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory)
{

  //Get Blackboard Component
  UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
  if (BlackboardComp == NULL)
  {
    return;
  }

 if (!PlayerKey.IsSet()) {
    //Retrieve Player and Update the Blackboard
    TArray<AActor*> FoundActors;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), PlayerClass, FoundActors);
    if (FoundActors[0]) {
      UE_LOG(LogTemp, Warning, TEXT("Found Player"));
      BlackboardComp->SetValueAsObject(PlayerKey.SelectedKeyName, FoundActors[0]);
    }
 }

}

现在,关于TickNode()函数的实现,我们首先检索黑板组件

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 //Get Blackboard Component
 UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
 if (BlackboardComp == NULL)
 {
 return;
 }

 *//[REST OF THE CODE]*

}

然后,我们需要从OwnerComp中检索 AI 控制器并检查其是否有效:

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 *//[PREVIOUS CODE]*

 //Get AI Controller
 AAIController* AIController = OwnerComp.GetAIOwner();
 if (!AIController) {
 return;
 }

 *//[REST OF THE CODE]*

}

一旦我们有了 AI 控制器,我们需要将其转换为我们的AChasingAIController(我们在上一章中创建的)并检查其有效性。因此,此服务仅当 AI 代理由ChasingAIController控制时才会工作:

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 *//[PREVIOUS CODE]*

 //Get ChasingAIController (the controller we have created in the previous chapter)
 AChasingAIController* ChasingController = Cast<AChasingAIController>(AIController);
 if (!ChasingController) {
 return;
 }

 *//[REST OF THE CODE]*

}

ChasingAIController中,我们可以通过使用CanSeePlayerKey变量检索(当前)CanSeePlayer并将其保存在黑板中:

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 *//[PREVIOUS CODE]*

 //Update the Blackboard with the current value of CanSeePlayer from the Chasing Controller
 BlackboardComp->SetValueAsBool(CanSeePlayerKey.SelectedKeyName, ChasingController->bCanSeePlayer);

 *//[REST OF THE CODE]*

}

如果私有LastCanSeePlayer变量(包含上一次 Tick 的CanSeePlayer的值)与当前的CanSeePlayer不同(这意味着玩家要么进入了我们的代理的视线,要么离开了),那么从ChasingAIController中检索LastKnownPlayerPosition并使用LastKnonwPositionKey变量将其保存在黑板中:

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 *//[PREVIOUS CODE]*

 //If the LastCanSeePlayer is different from the current one, then update the LastKnownPlayerPosition
 if (ChasingController->bCanSeePlayer != bLastCanSeePlayer) {
 BlackboardComp->SetValueAsVector(LastKnownPositionKey.SelectedKeyName, ChasingController->LastKnownPlayerPosition);
 }

 *//[REST OF THE CODE]*

}

在之前的检查之后,我们需要使用当前的值更新LastCanSeePlayer,以便在下一个 Tick 中我们将有正确的值:

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 *//[PREVIOUS CODE]*

 //Update the LastCanSeePlayer with the current CanSeePlayer
 bLastCanSeePlayer = ChasingController->bCanSeePlayer;

 *//[REST OF THE CODE]*

}

最后,我们可以调用父TickNode()(按照良好实践):

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

 *//[PREVIOUS CODE]*

 //Call to the parent TickNode
 Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

}

我们服务的代码现在已完成。为了方便起见,以下是TickNode()函数的代码:

void UBTService_UpdateChasing::TickNode(UBehaviorTreeComponent & OwnerComp, uint8 * NodeMemory, float DeltaSeconds)
{

  //Get Blackboard Component
  UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
  if (BlackboardComp == NULL)
  {
    return;
  }

  //Get AI Controller
  AAIController* AIController = OwnerComp.GetAIOwner();
  if (!AIController) {
    return;
  }

  //Get ChasingAIController (the controller we have created in the previous chapter)
  AChasingAIController* ChasingController = Cast<AChasingAIController>(AIController);
  if (!ChasingController) {
    return;
  }

  //Update the Blackboard with the current value of CanSeePlayer from the Chasing Controller
  BlackboardComp->SetValueAsBool(CanSeePlayerKey.SelectedKeyName, ChasingController->bCanSeePlayer);

  //If the LastCanSeePlayer is different from the current one, then update the LastKnownPlayerPosition
  if (ChasingController->bCanSeePlayer != bLastCanSeePlayer) {
    BlackboardComp->SetValueAsVector(LastKnownPositionKey.SelectedKeyName, ChasingController->LastKnownPlayerPosition);
  }

  //Update the LastCanSeePlayer with the current CanSeePlayer
  bLastCanSeePlayer = ChasingController->bCanSeePlayer;

  //Call to the parent TickNode
  Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

}

保存并编译服务。现在,你可以在行为树中使用它了。

这就是服务放置在行为树中的样子:

服务(在行为树编辑器中的详细信息面板)应如下所示:

在我们继续之前,就像我们对 DecoratorTask 所做的那样,将静态描述添加到 Service 中是一种良好的实践,这样我们就可以可视化我们分配了哪些键(Blackboard Key Selector 变量),以及 Player 类。在这里,我们需要在头文件中添加函数的签名,如下所示:

protected:

 virtual FString GetStaticDescription() const override;

对于实现(在 .cpp 文件中),我们只需返回一个格式化的 FString,其中包含我们需要显示的所有信息。我们可以使用 Printf() 函数轻松地格式化字符串。我在这里使用 Append() 函数来提高每一行的清晰度。特别是,我们需要显示哪一行是针对 PlayerClass 的,以及我们分配给每个 Blackboard Key Selector 变量的哪些值:

FString UBTService_UpdateChasing::GetStaticDescription() const
{
  return FString::Printf(TEXT("%s: '%s'"), TEXT("Player Class"), PlayerClass ? *PlayerClass->GetName() : TEXT(""))
    .Append(FString::Printf(TEXT("\n%s: '%s'"), TEXT("PlayerKey"), PlayerKey.IsSet() ? *PlayerKey.SelectedKeyName.ToString() : TEXT("")))
    .Append(FString::Printf(TEXT("\n%s: '%s'"), TEXT("LastKnownPositionKey"), LastKnownPositionKey.IsSet() ? *LastKnownPositionKey.SelectedKeyName.ToString() : TEXT("")))
    .Append(FString::Printf(TEXT("\n%s: '%s'"), TEXT("CanSeePlayerKey"), CanSeePlayerKey.IsSet() ? *CanSeePlayerKey.SelectedKeyName.ToString() : TEXT("")));
}

现在,在行为树中,服务将如下所示:

现在我们已经实现了 Service,我们准备在下一章构建我们的 行为树

摘要

在这一章中,我们继续深入探讨了如何创建 行为树 的示例,并使用了迄今为止遇到的所有系统。

尤其是我们可以看到如何设置测试环境并创建玩家角色和追逐代理。后者需要正确的控制器,并且还需要激活RVO 避障

然后,我们实现了我们的 更新追逐行为服务,并探讨了如何在 C++ 和 Blueprint 中实现。

在下一章中,我们将继续这个示例并构建最终的行为树。到下一章结束时,我们将完成这个项目,并拥有我们的 追逐行为。所以,让我们继续前进!

第十章:设计行为树 - 第三部分

本章是上一章的延续,是设计行为树的最后一部分。我们将完成我们开始的工作。特别是,我们将构建最终的行为树并使其运行。

特别是,我们将涵盖以下主题:

  • 生成黑板行为树资产

  • 设置黑板(Blackboard)以便与行为树一起使用

  • 实现行为树(使用蓝图或 C++节点)以创建追逐行为

  • 使行为树运行(在蓝图或 C++中)

  • 改进行为树的 C++节点以更好地符合最佳实践

再次强调,我们将同时在蓝图和 C++中实现一切,以给你一个更广泛的想法,了解你可以使用什么。或者,如果你已经知道你想要使用什么,你只需遵循两种实现中的任何一个。

这将结束我们从零开始设计行为树的旅程,最终我们将拥有完整的功能追逐行为。

因此,无需多言,让我们开始构建行为树!

构建行为树

创建追逐行为的最后一步是构建行为树。

在这个阶段,如果你觉得你遗漏了什么,只需回顾一下预期行为(我们在第八章中描述的那个)并制作一个清单,列出你需要构建这个行为树所需的内容。然而,即使你确实遗漏了什么,也不要担心——你可以在稍后阶段创建它。

许多开发者开始开发行为树,并在需要时构建节点。除非你真的很擅长或者树特别简单,否则始终建议提前做一些规划,就像我们在前两章中所做的那样。通过这样做,你将避免以后出现很多头痛的问题,并且通过在开始时稍微增加一些工作量,你可以避免大量的错误修复时间成本。当然,你仍然需要进行错误修复,但规划应该会减少引入错误或实现与最初计划不同的行为的机会。

从现在开始,你可以使用蓝图和 C++实现(如果你之前两者都使用过),或者只坚持使用你迄今为止一直在使用的那个。我将使用蓝图实现中的名称,但使用我们的 C++节点的概念是完全相同的。最后,我会展示一个使用 CPP 节点构建的行为树截图。

值得注意的是,我们还可以创建一个混合树。实际上,我们可以在同一个树中使用 C++和蓝图节点。由于在我们的案例中,我们在蓝图和 C++中都有每个节点的副本,我们可以随意使用任何一种。然而,这并不正确,因为我们已经创建了一些依赖于特定于该实现的 C++ AI 控制器的节点。幸运的是,在一个项目中,您不会对一切都有副本,所以如果您有一个特定的 AI 控制器,蓝图和 C++节点都应该引用同一个。

在您项目的开发过程中,请记住,您可以在 C++中创建一些行为树节点,在蓝图中也创建一些。一些开发者使用蓝图原型化他们的节点,然后将开发转移到 C++。尝试找到 C++和蓝图之间的公式和最佳平衡,这对您或您的团队来说是最有效的。

首先,如果您还没有创建,请创建行为树资产黑板。在我的情况下,我将把行为树命名为BT_ChasingBehavior,黑板命名为BB_ChasingBlackboard。两者都放在 AI 文件夹中(我们创建蓝图节点的地方),如图所示:

图片

您可以通过创建子文件夹来稍微重新排列AI文件夹。例如,您可以为您的设计器创建一个子文件夹,为您的任务创建另一个子文件夹,为您的服务创建第三个子文件夹。无论如何,这是一个相对较小的示例,所以我们将保持文件夹不变。

设置黑板

让我们从打开黑板编辑器(双击资产)开始。如您所回忆的那样,我们需要有一个玩家引用。

因此,创建一个类型为对象新键

图片

将其重命名为Player,然后在详情面板下,在键类型(可能需要展开此选项),将基础类设置为我们的选择(例如,Player,我们在本章开头创建的类),如图所示:

图片

下一个要添加的目的地,其类型为 Vector。当玩家不在视线范围内时,这将有助于确定一个目标。说到这里,我们需要一个名为CanSeePlayer的第三个键,类型为布尔值,用于检查玩家当前是否在视线范围内。这就是黑板应该看起来像的样子:

图片

构建树

双击BT_ChasingBehavior资产以打开行为树编辑器。确保您已选择树中的BB_ChasingBlackboard,如图所示:

图片

节点开始,我们需要一个选择器。这个选择器将是树将分为两个分支的地方:一个是在视线中追逐玩家时,另一个是在他/她不在视线中时。在这个选择器上,我们需要附加我们的BTService_UpdateChasingBehavior服务(或者如果您愿意,它的 C++版本,命名为UpdatedChasing)。别忘了在详细面板中分配所有变量(三个黑板变量),如下面的截图所示:

图片

在 C++版本中,我们还需要分配玩家类,以及黑板变量(因为这是我们用 C++设计服务的方式)。因此,您将得到类似以下的内容:

图片

一旦我们分配了所有变量,那么当附加到选择器节点时,我们的服务在行为树中的样子如下所示:

图片

选择器中添加两个序列节点(每个代表树的两个分支)。选择器将选择哪个取决于我们将放置在这两个节点上的装饰器。

将两个序列节点添加到BTDecorator_CheckBoolVariableBP(或其 C++版本的CheckBoolVariable)。在详细面板中,需要将要检查的布尔变量变量设置为CanSeePlayer黑板键,如下面的截图所示:

图片

然而,对于右侧的序列,您应该将逆条件复选框设置为true。通过这样做,我们可以检查CanSeePlayer是否设置为false。这有点啰嗦,但以下是详细面板中的最终结果:

图片

到目前为止,我们的树看起来如下所示:

图片

从左侧的序列节点,我们只需使用移动到任务来追踪玩家。您需要选择玩家黑板变量作为黑板键,如下面的截图所示:

图片

这是树的当前阶段:

图片

从右侧的序列节点,我们需要两个任务。第一个是移动到,但这次选择目的地变量作为黑板键,如下面的截图所示:

图片

这就是到目前为止的树的样子:

图片

第二个任务是我们已经创建的,BTTask_FindRandomLocationBP(或 C++版本的Find Random Location)。我们需要将DestinationKey设置为Destination 黑板变量,至于Radius,我们可以选择一个值(例如 30000,是默认值的十倍)。这就是详细信息面板的样子:

截图

这就是完整的树应该看起来像的样子:

截图

看起来我们已经完成了,但我们还有一件事要做。事实上,目前,装饰器在 AI 执行子树时不会控制子树的流程。事实上,我们希望如果玩家不再在视野中,就中止移动到玩家的任务;另一方面,如果智能体正在前往随机位置,我们希望如果玩家再次出现在视野中,智能体去追逐玩家。

要实现这一点,我们需要选择我们的装饰器(一次一个)并将观察者中止设置为自我,如下面的截图所示:

截图

如果装饰器仍然被选中,将要被中止的节点将在树中高亮显示:

截图

树略微改变以反映这种行为(在装饰器下,显示中止条件):

截图

如果你使用 C++节点构建了树,你将得到类似这样的东西:

截图

此外,你应该注意,在节点名称下方,并非所有信息都会显示(例如,在装饰器中,它不会说明其条件是否反转以及中止条件是什么)。在章节的后面,我们也会解决这个问题。

如你所见,结构非常简单(我见过在不同的树中实现相同的行为),但它包含了设计行为树的所有主要概念(包括创建每种类型的节点:装饰器服务任务)。结构简单并不意味着代表它的行为也简单。事实上,我们构建的是一个非常好的追逐行为

我们还没有涉及到简单的并行节点,但那些在更复杂的树中用于特定子树的行为。你不必担心——一旦你开始掌握创建行为树的艺术,使用简单并行节点将变得自然。

最后要做的事情是让这个行为树运行,然后在游戏中对其进行测试!

运行行为树

我们已经创建了整个设置,包括演员、控制器、感知和导航。然而,我们没有使这个行为树在我们的智能体上运行的任何代码。当然,我们将涵盖蓝图案例和 C++案例。

使用蓝图控制器运行行为树

如果我们已设置蓝图控制器,我们可以轻松地修改它,以便立即运行行为树。

事实上,一旦我们打开编辑器,我们可以在重写Event OnPossess后添加运行行为树节点,并选择正确的行为树,如图所示:

图片

保存它,然后你就可以开始了!运行游戏,看看它是否工作(当然,AI 控制器需要设置为BP_ChasingAIController)。

使用 C++控制器运行行为树

不幸的是,对于 C++来说,这并不像我们已经在第二章行为树和黑板中看到的那样直接。特别是,我们有两种选择:我们硬编码值或使用蓝图获取树的引用。

第一个选项对于这类东西不太实用,而且也不是最佳实践。

对于第二种选项,我们有多种选择。特别是,我建议你在控制器中创建一个行为树变量并使用它,以便它可以在OnPossess()函数上运行。然后,我们可以在蓝图中的这个类中创建一个子类,在那里我们可以轻松地分配这个变量。最后,我们可以更改对AIChasingAgent控制器的引用。

或者,你可以将行为树放在 AI 将要控制的Character/Pawn上,就像我们在第二章中所做的那样。这将是最理想的方法;然而,在这个时候,看到不同的替代方案是好的,以防你处于需要直接在控制器上使用行为树的情况。

让我们从打开我们的 C++控制器头文件并添加以下公共变量开始(使用UPROPERTY()宏,因为它需要在蓝图中进行编辑):

  UPROPERTY(EditAnywhere)
  UBehaviorTree* BehaviorTree;

然后,我们需要重写OnPossess()函数:

   virtual void OnPossess(class APawn* InPawn) override;

接下来,在.cpp文件中,我们需要包含行为树类,因此我们需要添加以下语句:

#include "BehaviorTree/BehaviorTree.h"

最后,在OnPossess()的实现中,我们只需运行行为树:

void AChasingAIController::OnPossess(APawn * InPawn)
{
  Super::OnPossess(InPawn);
  if (BehaviorTree != nullptr) {
    RunBehaviorTree(BehaviorTree);
  }
}

编译代码后,我们可以在 C++控制器上右键单击并选择基于 ChasingAIController 创建蓝图类,如图所示:

图片

然后,我们可以将这个蓝图放在 AI 文件夹中,命名为CPP_ChasingAIController(以区分BP_ChasingAIController):

图片

它的蓝图编辑器应该会自动打开(如果没有,只需双击资产打开它)。在详细信息面板中设置行为树变量,如图所示(当然,我们需要设置行为树C++版本):

图片

编译并保存蓝图

最后,在AI_ChasingAgent蓝图上,让我们更改其设置(从Pawn选项卡中的详细信息面板),使其使用新的控制器:

图片

这就结束了如何在C++控制器上运行行为树

错误修正

如果你以为你已经完成了,嗯,那还不是。事实上,在设计行为树时,必须始终有一个调试阶段,检查一切是否按预期工作。实际上,我故意构建了一个不工作的树。你能找出问题吗?在继续阅读之前试一试。

第一个问题在于,直到玩家出现在视野中,黑板上的目的地键永远不会初始化。此外,还有一个问题,当 AI 敌人寻找玩家的最后一个已知位置,但不可达时,它将失败任务。结果,序列将不允许进入下一个任务以选择一个随机目的地。我们如何解决这个问题?在继续阅读之前让我们试一试。

有许多方法可以做到这一点。例如,你可能想过使用“强制成功”装饰器。这绝对不是一个坏主意,实际上,这就是你可能会使用这个装饰器的情况(向序列添加一个可选分支,这样即使“移动到”失败,我们仍然可以选择一个随机目的地)。不幸的是,它与其他两个装饰器的设置不太兼容。

因此,另一种解决方案是以下方式修改树。我们需要将第二个序列替换为一个选择器,并有两个序列作为子节点。在第一个序列中,我们放置我们的“移动到任务”然后是“找到随机目的地”。在另一个序列中它们是相反的。结果,如果跟随最后一个已知玩家位置的任务失败,树可以回退到“找到一个随机位置”。如果你愿意,你也可以从第二个序列中移除最后一个“移动到”,但我留下它以提高清晰度;特别是对于那些难以理解行为树工作原理的人来说。最后,这是行为树(蓝图版本)应该看起来像的:

图片

这就是 C++版本应该改变的样子(下一节中的更改已经在这个图片中实现):

图片

当然,解决方案不是唯一的,你可能会找到更好的方法来做这件事。

关于我们的第一个问题,即目的地永远不会初始化?嗯,有了提出的解决方案,我们就不再有这个问题了,因为如果第一个序列(在右分支)失败,那么第二个将把目的地设置为一个随机位置。实际上,在调试行为树时,你总是需要小心每个修改以及它对整个树的影响。想象一下复杂行为的情况,你就可以得到这种任务所需时间的概念。

一旦我们解决这个问题,我们就可以检查其他错误,或者尝试改进设置。以下是一些其他问题,我将留给你去解决,以便练习使用行为树:

  • 当行为树开始执行时,目的地被设置为零向量。这意味着,如果 AI 没有看到玩家,它将直接前往世界原点。你能尝试避免这种情况吗?试着思考一下,我们有哪些不同的选择?最好的选择是在范围内有一个随机目的地。我们如何实现这一点?

  • 目前,当我们进行感知系统中的转换时,我们只是选择一个更广泛的BehaviorTreeDesignCharacter类。然而,如果你在级别中有多个 AI,这会导致问题。你该如何解决这个问题?当然,你可以将转换改为更具体的东西,仅将其限制为玩家。但如果你不能这样做,因为玩家和友军 AI 必须共享同一个类,你该怎么办?你可以尝试使用不同的队伍来区分敌人、盟友和中立者;回想一下第五章。

当然,这只是一个非常小的行为树示例,但要详细讲解它需要三个完整的章节。我将留给你探索游戏中的行为树,但在那之前,下一节将讨论一些关于如何改进 C++代码的建议。

进一步改进(仅限 C++)

我们通过编程不同的节点创建了一个非常好的行为树。然而,当你在一个大项目或与其他团队成员一起工作时,你应该确保你的节点尽可能稳固。实际上,在 C++实现中,我们在节点中添加了一个静态描述,以显示设置了哪些变量,这真是太棒了。但我们还能做更多!

本节将指导你进一步改进 C++节点。

节点名称

Unreal 在 C++行为树节点方面做得很好,通过去除前缀(例如"BTTask_"),直接显示任务(或装饰器或服务)的名称。在蓝图界面中,它保留了整个前缀,如下面的截图所示:

正如我们在前面的章节中看到的,你可以通过更改“详情”面板中的“节点名称”属性来修改将显示的名称:

这在行为树中得到了反映:

因此,当你编写 C++节点时,一个好的做法是提供一个默认的节点名称。你可以通过在构造函数中简单地分配它来实现这一点。所以,让我们为我们创建的所有三个 C++节点做这件事。

装饰器头文件中,我们需要添加构造函数的声明:

 UBTDecorator_CheckBoolVariable(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

.cpp文件中的实现很简单,因为我们只需要添加以下内容:

UBTDecorator_CheckBoolVariable::UBTDecorator_CheckBoolVariable(const FObjectInitializer & ObjectInitializer)
  : Super(ObjectInitializer)
{
  NodeName = "Check Bool Variable";
}

这是最终结果:

我们也需要对我们的任务做同样的事情。所以,让我们在头文件中声明构造函数:

 UBTTaskNode_FindRandomLocation(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

它在.cpp文件中的实现如下:

UBTTaskNode_FindRandomLocation::UBTTaskNode_FindRandomLocation(const FObjectInitializer & ObjectInitializer)
  : Super(ObjectInitializer)
{
  NodeName = "Find Random Location";
}

这就是它在行为树编辑器中的样子:

图片

最后,我们已经有了我们的 Service 构造函数,所以我们只需要在.cpp文件中添加一行:

UBTService_UpdateChasing::UBTService_UpdateChasing(const FObjectInitializer& ObjectInitializer)
  : Super(ObjectInitializer)
{
 NodeName = "Update Chasing Behavior";

  bNotifyBecomeRelevant = false;
  bNotifyCeaseRelevant = true;
}

该服务将在行为树编辑器中显示一个漂亮的名字:

图片

对装饰器的更好描述

正如我们之前提到的,当我们使用装饰器的 C++版本时,我们无法看到条件是否反转,或者中止设置。

要将它们添加到我们的静态描述中,我们需要稍微修改一下代码。幸运的是,描述所有这些属性的头部文件在我们装饰器的父类中给出,所以我们不需要从头编写代码。我们需要将父函数的返回值(通过使用Super::GetStaticDescription()函数)存储在一个本地的 FString 变量中。

然后,我们可以添加几行新代码,并附加我们创建的原生静态描述。最后,我们返回新的变量:

FString UBTDecorator_CheckBoolVariable::GetStaticDescription() const
{
  FString ReturnDesc = Super::GetStaticDescription();
  ReturnDesc += "\n\n";
  ReturnDesc += FString::Printf(TEXT("%s: '%s'"), TEXT("Bool Variable to Check"), BoolVariableToCheck.IsSet() ? *BoolVariableToCheck.SelectedKeyName.ToString() : TEXT(""));
  return ReturnDesc;
}

这就是最终的效果:

图片

当然,您也可以为任务和服务使用Super::GetStaticDescription()函数。

过滤黑板键

当我们在详细信息面板中插入黑板键时,我们可以插入黑板中存在的任何键。然而,当我们使用我们的检查布尔变量装饰器时,我们只想在要检查的布尔变量中插入布尔键。

我们可以通过在构造函数中添加一些过滤器来实现这一点,就像我们在第六章,“扩展行为树”中学到的那样。让我们为所有三个节点都这样做。

在我们的检查布尔变量装饰器构造函数.cpp文件)实现中,我们需要添加以下过滤器,以便它只能选择布尔键:

UBTDecorator_CheckBoolVariable::UBTDecorator_CheckBoolVariable(const FObjectInitializer & ObjectInitializer)
  : Super(ObjectInitializer)
{
  NodeName = "Check Bool Variable";

 BoolVariableToCheck.AddBoolFilter(this, GET_MEMBER_NAME_CHECKED(UBTDecorator_CheckBoolVariable, BoolVariableToCheck));
}

现在,我们的装饰器将只能接受布尔键:

图片

同样,我们也可以为我们的任务(类型为 Vector)做同样的操作。在其构造函数.cpp文件)中添加以下内容:

UBTTaskNode_FindRandomLocation::UBTTaskNode_FindRandomLocation(const FObjectInitializer & ObjectInitializer)
  : Super(ObjectInitializer)
{
  NodeName = "Find Random Location";

 DestinationVector.AddVectorFilter(this, GET_MEMBER_NAME_CHECKED(UBTTaskNode_FindRandomLocation, DestinationVector));
}

现在,我们的 随机位置查找任务 只能接受 Vector 键:

图片

最后,在我们的更新追逐行为服务中,我们需要做同样的操作,但针对每个三个变量。特别是,在对象过滤器中,我们需要指定一个类。在这个例子中,我们只需基于AActor进行过滤。因此,在.cpp文件中添加以下行:

UBTService_UpdateChasing::UBTService_UpdateChasing(const FObjectInitializer& ObjectInitializer)
  : Super(ObjectInitializer)
{
  NodeName = "Update Chasing Behavior";

  bNotifyBecomeRelevant = true;
  bNotifyCeaseRelevant = false;

 // Filter the Blackboard Key Selectors
 PlayerKey.AddObjectFilter(this, GET_MEMBER_NAME_CHECKED(UBTService_UpdateChasing, PlayerKey), AActor::StaticClass());
 LastKnownPositionKey.AddVectorFilter(this, GET_MEMBER_NAME_CHECKED(UBTService_UpdateChasing, LastKnownPositionKey));
 CanSeePlayerKey.AddBoolFilter(this, GET_MEMBER_NAME_CHECKED(UBTService_UpdateChasing, CanSeePlayerKey));
}

这是我们尝试为我们的服务选择键时的样子:

图片

这就结束了本节,以及我们从零开始创建行为树的旅程。

摘要

在本章中,我们完成了在前两章中开始的工作。实际上,我们从设计阶段开始,从头构建了一个行为树,并在过程中实现了所有需要的组件(包括蓝图和 C++!)。

尤其是在本章中,我们看到了如何构建行为树,以及黑板;使行为树运行(无论是在蓝图还是 C++设置中);并通过分配节点名称、在装饰器中放置头文件以及根据其类型过滤黑板键选择器来改进节点的 C++实现。

本章总结了虚幻引擎 AI 框架的主要特性。然而,这并不意味着我们就此完成了对这个框架的研究。实际上,我们现在可以对 AI 进行广泛的调试,这是我们在下一章将要面对的主题。

第三部分:调试方法

在本节的最后部分,我们将探讨调试方法。实际上,这是一个不容小觑的话题,因为它对于成为一名专业的 AI 游戏开发者至关重要。能够分析、分析和可视化你编写的 AI,是实现预期行为和达到项目正确性能的关键。

最后一章总结了如何探索本书中提出(以及其他)概念的建议,以及关于 AI 系统的思考。

本节将涵盖以下章节:

  • 第十一章,AI 调试方法 - 记录

  • 第十二章,AI 调试方法 - 导航、EQS 和性能分析

  • 第十三章,AI 调试方法 - 游戏调试器

  • 第十四章,超越

第十一章:AI 调试方法 - 日志记录

在本章中,我们将探讨一系列我们可以用来调试我们的 AI 系统的方法。当然,它们可以通用,而不仅仅是用于 AI。然而,由于 AI 有时可能很棘手,掌握如何在 Unreal 中进行适当的日志记录可以在你需要找到和修复与 AI 相关的错误时节省时间。实际上,由于某些变量设置不当,可能值错误,我们最终没有执行代码的一部分,或者做出了错误计算。

在本章中,我们将涵盖以下主题:

  • 蓝图中的控制台日志和屏幕消息

  • C++中的屏幕消息

  • C++中的控制台日志记录

  • 创建自定义日志类别(C++)

通过掌握日志记录的艺术,你将能够轻松地跟踪你的值和正在执行的代码部分。此外,创建自定义日志类别允许你定义不同的日志级别,并根据你正在调试的内容(甚至是在运行时)更改你想要看到的日志数量。此外,通过更改一个变量,可以轻松地在编译时移除所有调试代码,从而使你的发布游戏尽可能流畅地运行。

话虽如此,让我们开始吧!

基本日志记录

在前面的章节中,我们已经了解了如何创建日志。例如,在第五章“代理意识”中,我们看到了如何同时在控制台和屏幕上打印所需的信息。然而,在本节中,我们将更详细地探讨这些概念,并学习如何在 Unreal 中掌握日志记录。

  • 屏幕消息:在调试阶段,你和你团队需要了解某些变量值,以便在玩游戏时进行持续测试。因此,最简单的方法是在屏幕上打印变量的值。我们可以在蓝图和 C++中以不同的方式实现这一点。

  • 控制台消息:这些消息会打印在控制台(实际上,不止一个)和日志文件中(这样即使游戏没有运行,你也可以分析日志文件来了解发生了什么(或出了什么问题))。

虽然在蓝图中有独特的函数可以同时在屏幕上打印和打印到控制台,但在 C++中,我们有单独的函数。实际上,Unreal 的日志系统非常强大,C++可以解锁其全部功能。

蓝图中的控制台日志和屏幕消息

当谈到蓝图时,我们有简单易用的调试节点可以使用。最常见的一个是打印字符串,但它的对应物,打印文本,也存在。以下是一个显示打印字符串和打印文本的截图:

截图

它们都标记为仅限开发使用,这意味着它们将不会在发布构建中工作。

它们的简单用法很简单。你只需要将一个字符串(或文本)连接到同名的变量。

然而,如果我们展开它们并查看它们的更高级选项,我们可以找到一个完整的参数列表,如下面的截图所示:

图片

让我们详细看看它们:

  • 字符串/文本:这是将在屏幕上显示的字符串或文本。因此,需要显示的任何信息都必须在这个字符串或文本中。

  • 打印到屏幕:如果为真,节点实际上将在屏幕上打印消息(默认为真)。

  • 打印到控制台:如果为真,节点将在控制台打印消息(默认为真)。

  • 文本颜色:这是字符串/文本将显示的颜色(默认为浅蓝色)。当你需要从视觉上区分不同的信息时,这很有用。在这种情况下,颜色非常有帮助。

  • 持续时间:这是消息将在屏幕上显示的时间。确保你有足够的时间阅读/吸收信息。当然,较长的消息需要更长的持续时间来阅读。

这就是它们在游戏中的显示方式:

图片

如果“打印到控制台”设置为真,相同的日志也会出现在控制台,如下所示:

图片

C++中的屏幕消息

在 C++中,我们有一个方便的函数可以打印屏幕上的消息。我们在UEngine类中这样做。获取它的最简单方法是通过使用 GEngine 变量,这是一个在所有地方都可用且包含UEngine类实例的全局变量。请记住,这个变量可能为空(例如,游戏正在运行在发布构建上)。因此,在使用它之前检查变量是一个非常好的做法,如下面的代码片段所示:

 if (GEngine) {
     //Do stuff with GEngine
 }

if语句中,我们可以使用GEngine变量来调用AddOnScreenDebugMessage()函数。正如你所猜测的,它会在屏幕上打印一条消息。这是它的完整声明:

void AddOnScreenDebugMessage(uint64 Key,float TimeToDisplay,FColor DisplayColor,const FString& DebugMessage, bool bNewerOnTop = true, const FVector2D& TextScale = FVector2D::UnitVector);

让我们逐一介绍不同的参数:

  • :这是一个唯一键,它被赋予屏幕上的“槽位”。当需要写入新消息,但已经有一个具有相同键的消息显示时,新消息将替换旧消息。这在你有经常更新的变量且不想在屏幕上显示大量调试消息时特别有用,尤其是当只有最后一个是相关的时候。记住,在打印相同信息但更新时使用相同的键。

在定义中,键参数是一个uint64。然而,有一个与int32一起工作的包装器。如果你将键定义为int32,不要担心,因为它们仍然可以正常工作。

  • 显示时间:这是消息需要在屏幕上显示的持续时间,以秒为单位。确保你有足够的时间阅读/吸收信息。当然,较长的消息需要更长的持续时间来阅读。

  • DisplayColor: 这是调试消息将显示的颜色。当您需要从视觉上区分不同的信息时,这很有用。在这种情况下,颜色非常有帮助。

  • DebugMessage: 这是将在屏幕上显示的字符串。因此,需要显示的任何信息都必须打包到FString中。

  • bNewerOnTop: 这是一个布尔值,仅在键值等于INDEX_NONE(表示-1 的值)时使用。如果为真,每条新消息都将显示在其他消息的顶部。否则,每条新消息都将放置在其他消息的下方(就像在正常控制台一样)。这是一个可选参数,默认设置为 true。实际上,与正常控制台不同,文本不会向下滚动,因此将最新信息放置在顶部可以保证它始终可供开发者使用。

  • TextScale: 这是文本的缩放比例,以FVector2D表示。当文本需要比其他文本更大或更小时,这对于可视化很有用。这是一个可选参数,默认设置为单元向量(无缩放)。

现在我们已经介绍了这些参数,其用法相当直接。您可以使用以下代码片段快速打印一些文本:

if (GEngine) {
    GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Turquoise, TEXT("Some text to Display"));
}

如果您在打印消息之前想检查是否已经存在(以避免覆盖已显示的信息),可以使用OnScreenDebugMessageExists()函数,该函数接受键作为参数(仅uint64,因为没有为int32提供包装器)并返回一个布尔值。如果它已经存在,则为真。以下是一个示例:

if (GEngine) {
    bool bExistMessageInSlot2 = GEngine->OnScreenDebugMessageExists(2);
    // ... continue
}

如果您希望完全清除屏幕上的任何显示消息,可以使用ClearOnScreenDebugMessages()函数——没有参数,没有返回值。以下是一个示例:

if (GEngine) {
    GEngine->ClearOnScreenDebugMessages();
}

如果您想暂时抑制任何屏幕上的消息,可以通过更改bEnableOnScreenDebugMessages布尔值来实现。这个变量的优点是实时更改其值允许您在飞行中暂停屏幕调试。其用法非常简单:

if (GEngine) {
    //Disable On-Screen Messages
    GEngine->bEnableOnScreenDebugMessages = false;
}

要测试此代码,我们可以创建一个新的 C++类,类型为Actor,如下面的截图所示:

图片

然后,我们可以将其重命名为LoggingActor,并将其放置在名为Chapter11的文件夹中:

图片

接下来,我们需要在头文件(.h)中添加BeginPlay()函数覆盖,如果它尚未存在(取决于您使用的引擎版本,或者如果您在 Visual Studio 中创建了该类)。如果没有,这是代码:

protected:
  // Called when the game starts or when spawned
  virtual void BeginPlay() override;

然后,在实现文件(.cpp)中,我们可以在BeginPlay()函数中编写将消息记录在屏幕上的函数:

// Called when the game starts or when spawned
void ALoggingActor::BeginPlay()
{
  Super::BeginPlay();

  if (GEngine) {
    GEngine->AddOnScreenDebugMessage(-1, 8.0f, FColor::Turquoise, TEXT("Some text to Display"));
  }

}

编译此代码并将演员放置在级别中(如果已经存在蓝图,则移除它,或者如果您更喜欢,则保留它)。

请记住,我们没有为这个 Actor 创建场景组件,因为我们专注于逻辑。因此,你能够将其放置在关卡中,但不能放置在特定位置。就我们的目的而言,这没问题,但请记住,当你创建 C++ Actor 时,你可能需要一个场景组件(至少),或者一些其他组件。

一旦你按下播放,你将能够看到以下消息:

C++ 控制台日志

当涉及到使用 C++ 在控制台打印时,可能性比蓝图要广泛得多。事实上,在 Unreal 中,日志记录是一个复杂的野兽,可以做很多事情。

如果你只是想快速地在控制台打印一些内容使用 C++,你只需使用以下代码行:

UE_LOG(LogTemp, Warning, TEXT("Message to Display"));

这就是它在输出日志中的显示方式(我已经将此代码行放置在 LoggingActor 中):

然而,如果你想了解 Unreal 日志系统 的潜力,那么请继续阅读。

一个好的日志系统不仅仅显示信息——它需要显示正确的信息。

因此,要开始了解 Unreal 的严肃日志记录,我们需要了解两个概念:日志类别日志详细程度

  • 日志类别:日志类别就像标签,用于给调试消息分类:它们将这些消息传达的信息分类到不同的类别中。例如,你可以有一个 AI 类别,另一个 UI 类别,还有一个游戏玩法类别。因此,当消息出现在控制台时,可以知道该消息属于哪个类别。

    然而,类别的有用性并不仅限于此。想象一下,你正在调试你的 AI,但当你玩游戏时,你会被你的队友(或你,在另一个时间切片)用于测试的游戏玩法调试消息淹没。在这种情况下,只看到你的调试消息会很好。如果所有消息都在不同的日志类别中,你可以过滤它们。因此,你可以完全抑制来自特定日志类别的调试消息。

    使用 日志详细程度 可以更进一步。

  • 日志详细程度:日志详细程度是另一个标签,用于指示调试消息的 "详细程度"。换句话说,调试消息的详细程度。

    例如,一条显示 "AI 控制器发生错误" 的消息和另一条显示 "在行为树中执行 AttackPlayer 任务时 AI 控制器发生错误" 的消息传达了相同的错误,但后者更为详细。游戏玩法程序员可能需要意识到 AI 控制器中存在错误,所以如果游戏表现不符合预期,可能并不是由于他的/她的代码,而是由于 AI 编程中的错误。相反,AI 程序员在尝试修复可能引起错误的问题时,需要了解更多关于错误的具体细节。

    对于日志记录,有七个有序的详细程度级别。一旦选择了详细程度级别,那么所有等于或低于所选详细程度的详细程度级别都将显示/记录到文件中。

这些是在 Unreal 中可用的不同详细程度。它们按从最不详细到最详细的顺序排列:

  • Fatal:这种详细程度总是会在控制台和日志文件中打印,并且它总是会崩溃系统,即使日志被禁用。这意味着每次你使用这个级别的日志时,它都会让你的游戏/应用程序崩溃。你可能只想在非常罕见和特殊的情况下使用它,如果运行时执行达到一个“致命”的无法返回的点,那么它将通过提供一些有用的信息来避免未来再次发生崩溃。

  • Error:这种详细程度既会在控制台打印,也会在日志文件中打印。它们默认以红色显示。

  • Warning:这种详细程度既会在控制台打印,也会在日志文件中打印。它们默认以黄色显示。

  • Display:这种详细程度既会在控制台打印,也会在日志文件中打印。

  • Log:这种详细程度只会在日志文件中打印,不会在控制台打印。然而,它们仍然可以在编辑器中的输出日志窗口中查看。这也是最常用的级别,因为它不会垃圾邮件式地填充控制台,你可以在输出日志窗口中接收所有信息。

  • Verbose:这种详细程度只会在日志文件中打印,不会在控制台打印。这通常用于详细的日志记录,尤其是在打印许多变量的值时。

  • VeryVerbose:这种详细程度只会在日志文件中打印,不会在控制台打印。这是最详细的日志级别,通常用于非常详细的日志消息,提供对情况的全面了解(例如,每个变量在过程中的每个值)。这类日志非常详细,几乎在输出日志中像垃圾邮件一样,因此只在真正需要这种详细程度时使用。

通过 Unreal 日志系统的力量,你可以为每个日志分类决定详细程度级别。这样,如果你是一个 AI 程序员,你可以将你的 AI 日志设置为 VeryVerbose,而其他分类(例如,与游戏玩法相关的内容)则只设置为 Log 级别。

创建自定义日志分类(C++)

到目前为止,我们已经看到了一个被设置为特定详细程度的日志分类是如何能够记录关于系统的正确数量的信息的。然而,真正的力量在于你能够创建自己的日志分类。这个过程相当直接,因为它依赖于几个宏的使用。

定义你分类的最佳位置是在 *YOUR_PROJECT_NAME.h**YOUR_PROJECT_NAME.cpp* 文件中,在我的情况下是在 UnrealAIBook.hUnrealAIBook.cpp 文件中。

在头文件(.h)中,我们需要声明以下宏:

DECLARE_LOG_CATEGORY_EXTERN(CategoryName, DefaultVerbosity, CompileTimeVerbosity);

这些是我们需要插入的参数:

  • 类别名称: 这是你要定义的新类别的名称。尽量提供一个有意义的名称,不要太长,但要足够描述性。选择取决于你的游戏/应用程序。实际上,如果你只有一个简单的 AI 系统,你可以将所有日志记录到同一个LogAI类别中。然而,如果你有一个非常大且复杂的 AI 系统,它由许多子系统组成,那么将它们分为不同的日志类别可能是一个明智的选择。

  • 默认详细程度: 这是如果没有指定其他部分(无论是在配置文件中还是在命令行中)时使用的默认详细程度。所有高于这个级别的信息都不会被显示。例如,如果你将这个级别设置为仅仅是"Log",那么详细或非常详细的日志都不会被记录。

  • 编译时详细程度: 这是编译器将包含日志指令的最大详细程度。任何比这个级别更详细的定义都不会被编译,这意味着它将不可用,即使详细程度被更改也是如此。例如,一旦你完成对一个系统的操作,你可能不想在代码库中的Tick函数内保留许多非常详细的日志指令。通过从编译后的代码中移除它们,你可以确保这些指令永远不会影响你的性能,特别是如果系统已经整合并且这些日志永远不会被读取。另一个用途可能是在发布游戏时,你不想在游戏中存在特定日志类别的某个详细程度。

为了举例,我们可以将以下内容添加到我们的项目中:

DECLARE_LOG_CATEGORY_EXTERN(MyAwesomeAILogCategory, Log, All);

.cpp文件中,我们需要有以下宏:

DEFINE_LOG_CATEGORY(CategoryName);

你需要插入的唯一参数是类别的名称,并且它必须与你在头文件中插入的名称匹配。

因此,在我们的情况下,我们可以使用以下内容:

DEFINE_LOG_CATEGORY(MyAwesomeAILogCategory);

完成这些后,你就可以使用你全新的日志类别了,这样你就可以以下述方式添加你的 C++代码:

UE_LOG(MyAwesomeAILogCategory, Log, TEXT("I'm logged from a custom Category!"));

如果你遇到编译错误,可能是因为你放置日志的 C++代码没有访问类别定义的权限。例如,在我的情况下,我必须包含我的通用项目(/模块)头文件才能使其工作;我添加了#include "UnrealAIBook.h"

这就是它在输出日志中的显示方式:

图片

摘要

在本章中,我们探讨了如何在 C++和蓝图中进行日志记录。这使我们能够轻松地发现代码中错误的部分,或者包含错误值的变量。

了解自定义分类特别有用,因为这是 Unreal 中日志系统的一个非常强大的部分。实际上,它允许我们为游戏中的每个部分创建一个特定的分类,并根据重要性增加或减少每个分类的消息数量(甚至可以在运行时进行),此外,它还允许我们在需要发布游戏时轻松移除调试代码,这可以通过简单地更改 DECLARE_LOG_CATEGORY_EXTERN() 宏的值来实现。

在下一章中,我们将探讨在本书中遇到的 AI 工具进行调试的更多具体工具,从 导航系统EQS。掌握这些工具对于成为 Unreal 的 AI 程序员至关重要,并且当您创建复杂的 AI 系统时,它们将非常有用。

第十二章:AI 调试方法 - 导航、EQS 和性能分析

欢迎来到第十二章,AI 调试方法 - 导航、EQS 和性能分析

在这里,我们将探索 Unreal Engine 内置的一些针对 AI 系统的特定工具。我们将重点关注导航EQS,分别涵盖我们在第三章,导航和第四章,环境查询系统中未涵盖的内容。

在本章结束时,我们将看到一些与 AI 代码性能分析相关的更多工具。在下一章中,我们将通过探索游戏调试器来完善讨论,以便为我们的 AI 提供快速实时反馈。

在本章中,我们将涵盖以下主题:

  • 检查行为树的执行情况

  • 使用 EQS 测试代理可视化环境查询,并探索其设置如何帮助更好地理解查询

  • 如何使用 EQS 性能分析器来识别存在性能问题的查询,以及如何深入了解以了解导致性能不佳的原因

  • 可视化导航网格及其内部工作原理

  • 使用导航测试代理检查导航网格中两点之间的路径

  • 使用AI 统计组进行性能分析以收集有关 AI 系统性能的有用信息

  • 创建一个自定义统计组,以便分析您自定义 AI 系统的性能

那么,让我们开始吧!

调试行为树

在深入本章的其余部分之前,我们应该学习如何调试行为树。实际上,有许多方法,其中一些我们将在本章(与统计数据相关)和下一章(如游戏调试器)中探讨。

然而,我想指出,可以看到行为树的执行情况。如果您在玩游戏时保持行为树编辑器打开,您将看到行为树上正在执行的当前分支,以及哪些装饰器被阻塞。此外,在黑板面板中,可以检查每个黑板值的当前值。以下是从设计行为树项目中的一个示例:

此外,如果您有多个敌人正在运行行为树,您可以通过顶部的菜单更改要查看的敌人,如图所示:

同样,也可以看到蓝图执行的流程。这虽然与 AI 没有直接关系,但值得在信息框中提及。

分析并可视化环境查询

在本节中,我们将探讨如何可视化和分析环境查询。实际上,我们将更好地理解 EQS 测试棋子如何可视化环境查询,并且我们将探索分析器工具,该工具允许我们检查每个查询的性能。

使用 EQS 测试棋子可视化环境查询

如在第四章,环境查询系统中预期的那样,有一个简单内置的方法可以在游戏世界中可视化环境查询,直接从视图中;游戏甚至不需要运行。事实上,有一个特殊的棋子能够做到这一点。然而,这个棋子不能直接带入关卡,因为它已被在代码库中声明为虚拟,以确保它不会被滥用。这意味着为了使用它,我们需要创建自己的蓝图棋子,该棋子直接从这个特殊棋子继承。

幸运的是,完成此步骤后,棋子功能齐全,不需要更多的代码,只需与参数一起工作(例如,您想要可视化的环境查询)。

您需要启用环境查询系统,查看第四章,环境查询系统,了解如何做到这一点。

如果您已经在第四章,环境查询系统中创建了 EQS 测试棋子,请随意跳过下一节。

创建 EQS 测试棋子

首先,创建一个新的蓝图;要继承的类是EQSTestingPawn,如下面的截图所示:

图片

然后,您可以将其重命名为MyEQSTestingPawn,或者如果您已经在第四章,环境查询系统中这样做,您可以跳过这部分,或者给它另一个名字。

如果您只是从详细信息面板将其拖入地图,您可以更改 EQS 设置,如下一个截图所示:

图片

在第四章,环境查询系统中,我们已走到这一步,但现在我们有一些更多的时间进行调试,让我们深入探讨。

创建测试环境查询

我们需要一个环境查询来执行,以便可视化不同设置中的情况。因此,我们需要准备一个简单的查询,它在网格中生成点,然后根据与查询者的距离进行评分。所以,让我们构建环境查询,如下面的截图所示(在 EQS 编辑器内):

图片

我们将保留默认设置,但为了您的方便,这是简单网格生成器在详细信息面板中的样子:

图片

对于距离测试,我们将主要保留默认值,但为了展示目的,我们可以更改过滤设置,使浮点值最小浮点值最大分别为 200 和 1,000。结果,我们将能够过滤掉离询问者太近的点,并查看 EQS 测试棋子如何可视化这些点:

用于可视化环境查询的 EQS 测试棋子的设置

现在让我们探索我们在详细信息面板中看到的MyEQSTestingPawn的设置。为了您的方便,以下是设置截图:

记住,您需要在该级别中选择测试棋子以可视化查询(并且必须设置查询模板)。

  • 查询模板:正如其名所示,这是我们想要可视化的环境查询

  • QueryParams_DEPRECATED:(不要使用此选项)这是设置环境查询参数的旧方法;现在请使用查询配置

  • 查询配置:这是一个数组,允许您快速更改环境查询的设置,以便快速测试和调试。

  • 高亮模式:它决定了哪些位置(或项目)应该有一个高亮显示的视觉表示,这意味着有一个大球体,而不是小球体。可能假设的值如下:

    • 全部:这意味着所有位置或项目都会被突出显示,这是默认选项;因此,它将正好如以下截图所示,这也是我们设置查询模板后的默认显示方式:

视口。这是视口的图像。其他(重叠)信息在这里并不重要

    • 最佳 5%:正如其名所示,它仅显示那些得分在所有点中最佳 5%范围内的点。所有其他点将有一个更小的球体:

视口。这是视口的图像。其他(重叠)信息在这里并不重要

    • 最佳 25%:与上一个选项一样,它仅显示那些得分在所有点中最佳 25%范围内的点。所有其他点将有一个更小的球体:

视口。这是视口的图像。其他(重叠)信息在这里并不重要

  • 绘制标签:如果选中,它会在点旁边显示其分数。如果一个点已被过滤掉,它将显示哪个测试过滤掉了该点。在我们的例子中,靠近询问者的点已被距离测试过滤掉。此选项默认启用;如果我们将其关闭。

  • 绘制失败的项目:如果选中,它还会显示那些已被过滤掉的点。默认情况下是启用的,如果我们将其关闭,在我们的例子中,我们会看到以下内容:

  • 仅重跑完成移动的查询: 如果勾选,则仅在测试棋子停止移动时执行查询,而不是一直执行。默认情况下启用,出于性能原因,你应该保持开启状态,除非你需要当测试棋子移动时也可视化查询:

  • 在游戏中应可见: 如果勾选,则将在游戏中以小图标的形式显示测试棋子,如这个截图所示;默认情况下是禁用的:

  • 游戏过程中勾选: 如其名所示,如果勾选,则允许在游戏执行过程中勾选。

  • 查询模式: 确定查询的最终结果,并且有许多可能的选项:

    • 全部匹配: 这是默认选项;它显示所有与查询匹配的点(蓝色已被过滤掉)。此外,点根据分数从橙色到绿色着色,绿色代表最高分数 1:

视口。这是视口的图像。其他(重叠)信息在此处不重要

    • 最佳单个项目: 以绿色显示得分最高的点(因此是最佳点),其他所有点以较深的绿色阴影显示(已被过滤的点仍然以蓝色显示):

视口。这是视口的图像。其他(重叠)信息在此处不重要

    • 从最佳 5%中随机选择一个项目: 它显示(或返回)最佳 5%中得分最高的点的随机一个。在下一个示例中,随机选择了一个点:

视口。这是视口的图像。其他(重叠)信息在此处不重要

    • 从最佳 25%中随机选择一个项目: 它显示(或返回)最佳 25%中得分最高的点的随机一个。在下一个示例中,随机选择了一个点:

视口。这是视口的图像。其他(重叠)信息在此处不重要

  • 导航代理属性: 由于一些环境查询依赖于导航系统,正如我们所见,因此这组选项允许您调整执行查询的代理在导航系统中的外观。我们不会深入探讨这些,但以下是可能的选项:

这样,我们就结束了关于使用测试棋子可视化环境查询的讨论。然而,下一节将介绍更多关于 EQS 的内容,我们将看到如何分析环境查询以检查其性能,而在下一章中,我们将看到如何在游戏调试器运行时可视化环境查询。

环境查询分析

在本节中,我们将学习如何快速分析环境查询。

您可能已经注意到,在 EQS 编辑器中,详细信息面板旁边有一个分析选项卡,如下面的截图所示:

截图

如果我们点击它,我们将有一个全新的部分,其中我们可以快速分析环境查询。目前,它是空的,因为游戏没有运行,没有执行任何环境查询:

截图

如果游戏中已经有一些查询正在运行,我们只需按Play并返回此菜单,其中包含所有当前正在运行的查询。然而,如果您游戏中没有任何查询,我们可以快速创建一个行为树来运行一些查询,使此行为树在 AI 控制器上运行,并将此 AI 控制器分配给游戏中的某个 AI 代理。由于我们在书中已经多次这样做,所以我就不会一步步指导您了。但是,下一节将概述这个过程,如果您想跟随,请随意。否则,您可以跳过下一节,直接进入环境查询分析器部分。

设置 EQS 分析测试资产

首先,我们需要创建环境查询;您应该在游戏中已经有了这些,因为您一直在使用它们,但如果您只想测试这个分析工具,创建几个查询,例如,EnvQueryAEnvQueryB。我使第一个查询比平常更重(生成许多点),以便在分析器中突出显示:

截图

然后,我们需要为行为树创建一个黑板资产;我们可以将其命名为BB_EQSTesting,并在其中只需要一个向量键:

截图

接下来,我们可以创建一个运行查询的行为树。将其命名为BT_EQSTesting,并在其中分配黑板。然后,我们可以通过序列节点(附带一点延迟)依次运行两个查询。请记住将查询分配给Run Query节点,并使用黑板中的向量键(这样查询就不会失败)。默认情况下,运行模式应设置为Single Best Item;请确保这是您选择的选项。以下是行为树:

截图

现在我们需要一个 AI 控制器;为了简单起见,我们可以在蓝图创建它,并将其命名为BP_EQSTestingAIController。重写On Possess函数,并运行行为树*:

截图

最后,在关卡中创建一个 AI 代理(如果您从第三人称****示例地图开始,可以复制玩家)并分配新创建的 AI 控制器:

截图

现在我们准备看到分析器在行动中的效果!

环境查询分析器

如果您已经走到这一步,您应该按播放按钮,并在您的游戏中运行环境查询。如果是这样,当游戏运行时,EQS 编辑器的分析器标签将填充所有正在运行的环境查询,并显示它们的统计数据:

对于每种查询类型,分析器都会显示其执行的次数、从最糟糕的查询中取出的最大时间以及它们的平均时间。

如您所见,EnvQueryA非常重(因为我就是这样设计的),分析器可以帮助您了解哪些需要更改/改进或甚至删除。此外,我们会看到红色,因为它的运行时间非常糟糕。对于EnvQueryB,我们会看到任何这些情况。

此外,分析器还会根据运行模式对查询进行分类。在下面的屏幕截图中,EnvQueryB有两个条目,基于运行模式是单个结果还是所有匹配

当然,当您看到分析器标签时,您已经在特定查询上打开了 EQS 编辑器。因此,在分析器的底部有一些关于当前打开 EQS 编辑器的查询的更多信息。实际上,我们可以看到一个显示查询的EQS 滴答预算负载(其滴答有多重)的图表。

最后,在最上方,显示了迄今为止记录了多少种查询类型,以及一个名为显示当前查询的详细信息的复选框。如果我们勾选此框,我们就能直接在环境查询树中看到每个生成器(及其选择率)的最坏和平均时间,以及对于每个测试,我们有以下内容:

这里也是用颜色编码的:

  • 红色表示非常糟糕的性能。

  • 黄色表示中间性能。

  • 绿色表示性能良好。

再次强调,EnvQueryA被设计成表现不佳,以展示分析器。如果我们选择EnvQueryB,我们会看到它表现得好得多:

因此,这种深入到每个单个生成器和每个测试的做法,让您能够深入了解您的环境查询中哪些部分实际上表现不佳。因此,您可以使用分析器来识别有问题的查询,然后深入到需要优化的那些查询中。

保存和加载 EQS 统计信息

分析环境查询的另一个酷特性是您可以保存您的统计信息并再次加载它们。这为您提供了强大且灵活的工具,可以与您的团队分享您的发现。

为了保存 EQS 统计信息,您只需按顶部菜单中的保存统计信息按钮,如以下屏幕截图所示:

您将被提示选择一个位置来保存包含您统计信息的文件。

加载也很简单。只需按顶部菜单中的加载统计信息按钮,如下一张屏幕截图所示:

图片

你将被提示选择一个包含你的 EQS 统计数据的文件,之后,所有你的统计数据将被加载。

测试和可视化导航网格

在本节中,我们将探索一些内置工具来测试和可视化导航网格

尤其是我们将看到如何在底层可视化导航网格,以及导航测试演员如何快速显示“路线”(由路径查找算法生成的路径)。

可视化导航网格

正如我们在第三章中提到的,导航,当我们通过将导航网格边界体积引入地图来生成导航网格时,我们也在这个级别中创建了一个RecastNavMesh-Default演员。如果我们选择它,我们可以看到有很多选项可以生成导航网格,其中一些我们已经探索过。然而,我们有一个关于显示设置的整个章节,在第三章的导航中,我们没有时间进行适当的探索。因此,让我们快速浏览这些设置;这里,为了你的方便,是详细信息面板中显示设置的截图:

图片

使用默认设置,它看起来是这样的(在我们的示例地图中):

图片

我们有很多设置,所以不深入细节,让我们直接进入:

要完全理解所有选项,你应该熟悉导航网格是如何生成的。然而,这部分内容超出了本书的范围。无论如何,你仍然可以尝试调整设置,并了解更多关于导航系统的情况。

  • 绘制三角形边:显示构成导航网格的三角形。最终,这些三角形的连接将在图上生成,路径查找算法将在其上运行(实际上,这比这更复杂,因为系统需要根据更大的世界进行缩放,并在不同级别的不同图上使用分层路径查找)。通过启用此选项,你可以实际看到这个图上的节点:

图片

  • 绘制多边形边:显示多边形的边。实际上,导航网格是从将级别分割成多边形开始的,如果一个多边形包含复杂的几何形状(例如,有静态网格),算法将根据几何形状将多边形细分为更小的多边形。然后,这些多边形被分割成三角形(我们之前看到的)。启用此选项后,你可以看到哪些多边形属于这个静态网格,如果你保持上一个选项开启,你可以清楚地看到所有这些多边形是如何被分割成三角形的:

图片

  • 绘制填充多边形:如果勾选,它会显示用我们已看到的常规绿色填充的多边形;实际上,此选项默认是开启的。但是,如果我们禁用它,我们可以更清楚地看到导航网格的“骨架”:

图片

  • 绘制导航网格边缘:如果勾选(默认情况下是勾选的),它会显示导航网格的边缘。在下面的屏幕截图中,这是关闭此选项时的样子:

图片

  • 绘制瓦片边界:如果启用,它会显示导航网格瓦片的边界:

图片

  • 绘制路径碰撞几何形状:通过启用此选项,可以可视化传递给导航网格生成器的几何形状,这基本上是导航系统“知道”的所有几何形状。这有助于检查是否被导航系统考虑,因此您可以包括或排除不需要的内容(请记住,有一个选项可以让演员和对象影响导航网格,此选项允许您找到当前被导航系统考虑的内容)。通过勾选此选项,可以看到以下内容:

图片

  • 然而,请注意,虚幻引擎独立渲染这个几何形状。因此,您也可以在引擎中使用其他视图来隔离这个几何形状,以便更好地检查其外观。例如,您可以打开线框视图仍然可以看到传递给导航网格生成器的几何形状,如下所示:

图片

  • 绘制瓦片标签:如果启用,这些选项显示导航网格每个瓦片的标签(以坐标表示):

图片

视口。这是视口的屏幕截图。其他(模糊显示)的信息在这里并不重要

  • 绘制多边形标签:如果启用,此选项为导航网格中生成的每个多边形显示一个标签(也表达了这个多边形在生成前经过了多少次迭代),:

图片

视口。这是视口的屏幕截图。其他(模糊显示)的信息在这里并不重要

  • 绘制默认多边形成本:如果此选项启用,它会显示导航网格不同部分的全部成本。这对于检查哪些部分更难穿越非常有用。通过在我们的示例中启用它,它看起来是这样的:

图片

视口。这是视口的屏幕截图。其他(模糊显示)的信息在这里并不重要

  • 如你所见,所有成本都是 1,这是因为我们没有其他类型的导航区域,只有默认的。如果我们引入一个导航修改器,并设置一个自定义的导航区域(不同于 null),例如,沙漠(或丛林区域,就像我们在第三章中做的,导航,这将得到这样的结果(你会注意到导航网格生成方式的变化,以及在沙漠区域成本更高):

图片

视口。这是视口的截图。其他(模糊显示)的信息在这里并不重要

  • 在路径节点上绘制标签:如果这个选项开启,它将在路径节点上绘制标签。

  • 绘制导航链接:如名称所示,如果这个选项开启,它将绘制导航链接。它默认开启,因为通常你希望能够看到导航链接。如果我们禁用它,在我们的示例中它将看起来像这样:

图片

  • 绘制失败的导航链接:这与前面的选项相同,但在失败的导航链接上默认是禁用的。

  • 绘制簇:如果启用,它允许你看到簇。我不会深入细节,但正如我们之前提到的,路径查找需要优化以适应大型世界(例如,分层路径查找);因此,使用这个选项,你可以看到导航网格的哪些区域是连接的(这意味着在两个区域之间以某种方式存在路径的保证),因此路径查找可以先找到连接的区域,然后再细化路径的搜索。如果这个选项被启用,这里就是它的样子:

图片

  • 绘制八叉树和绘制八叉树细节:如果启用,它允许你看到八叉树。八叉树是数学结构(有八个子节点的树),用于划分 3D 空间。实际上,导航网格只位于同一表面上,但它存在于(并且需要与)3D 空间中。就像在我们的示例地图中,我们有一些楼梯和一些导航网格的区域不在同一水平面上;还有导航链接将楼梯上方的区域连接到楼梯下方的区域。如果我们启用它,这里就是它应该的样子(你将能够注意到八叉树主要位于需要发展高度的导航网格部分):

图片

  • 绘制偏移量:正如你可能已经注意到的,导航网格不是绘制在关卡几何形状的同一水平面上,但存在一个小的偏移。绘制偏移量参数控制导航网格绘制地面上的这个偏移。默认值是 10(如果我们保持虚幻单位的惯例,这意味着 10 厘米)。如果我们改变这个值(我还启用了绘制填充多边形以更好地查看偏移),例如,到一个更高的值,这就是我们最终得到的结果:

  • 启用绘制:正如其名所示,如果启用此功能,就可以看到导航网格以及所有之前的设置。

当然,当我们开始调整其他决定导航网格如何生成的设置时,所有这些选项结合使用会更好。实际上,通过调整显示设置,您可以更好地理解生成设置的工作方式,并实际上“看到”它们如何影响导航网格的生成。

导航测试演员

就像我们在EQS 测试棋子中看到的那样,有一个内置的导航测试演员我们可以使用。

这个演员没有被声明为虚拟(就像 EQS 的对应物那样),因此它可以直接放置在地图上。实际上,我们可以从模式面板访问它,如图所示:

一旦放置在关卡中,它看起来就是这样:

如果我们在关卡中放置另一个,那么在详细信息面板中,我们可以在路径查找部分分配如下所示:

这将生成两个导航测试演员之间的路径预览:

这里是一个从不同角度的例子:

此外,如果我们修改偏移角距离,我们还可以在导航测试演员的路径查找部分“平滑”路径的边缘。例如,150 的值将产生以下路径:

当然,这个路径查找测试也可以与导航区域一起使用。如果我们在一个关卡中放置一个沙漠区域(在第三章,导航中创建的),路径查找器将尝试避开它,因为它有更高的成本。在下面的例子中(高亮显示的体积是沙漠区域),沙漠区域很小,穿过它仍然是最近的路径:

然而,如果我们扩大区域,那么从另一侧走会更便宜:

最后,值得一提的是,我们还可以在导航测试演员中使用导航过滤器,始终在其详细信息面板的路径查找部分。例如,我们可以放置NavFilter_DesertAnimal(我们在第三章,导航中创建的),然后沙漠区域甚至更受欢迎,产生这条其他路径:

这个导航测试演员有更多功能,正如您可以从其详细信息面板中看到的那样,但不幸的是,它们超出了本书的范围。然而,我们已经看到了它的基本用法。

AI 性能分析

当涉及到性能分析时,虚幻引擎提供了许多解决方案和工具。本节探讨了其中一些与 AI 相关的工具。

我们将特别介绍如何直接从控制台可视化统计信息以及如何创建自定义统计组。在本节结束时,我们将提及会话前端工具。

通过控制台进行性能分析

最常用的性能分析工具是通过控制台激活统计信息,因为它非常快速,你可以实时跟踪性能。实际上,只需在控制台中输入stats game,就会在屏幕上显示一整页的统计信息:

截图

这里显示了所有出现的统计信息:

截图

如你所见,有很多信息,但它相当通用,因为它跟踪了游戏的整体性能。这是一个开始优化游戏的完美起点;然而,作为一个 AI 开发者,你需要更具体的工具。

如果我们只输入Stat,屏幕上会显示一系列选项(作为建议)(86!):

截图

但我们可以通过输入Stat AI进一步细化我们的搜索,并获取与 AI 相关的统计信息(毕竟,这些选项是列表中的第一个,因为它们按字母顺序排列):

截图

当需要快速跟踪你的 AI 性能时,这些功能非常有用。

为了关闭统计信息,只需重新输入你关闭那些特定统计信息时使用的相同命令。

如果我们输入Stat AI,我们会得到一个通用的 AI 性能跟踪(这还取决于你激活了哪些 AI 系统)。在右侧,你还可以检查当前级别中有多少 AI,以及有多少 AI 正在渲染:

截图

输入Stat AI_EQS会给我们更多关于 EQS 的信息。当然,通过使用包含五个 AI 执行我们之前创建的EnvQueryAEnvQueryB的关卡,这将对 EQS 在这个特定示例中的性能产生巨大影响:

截图

输入Stat AIBehaviorTree会给我们关于正在运行的行为树的信息。目前,在我们的示例中,我们拥有非常简单的行为树,因此在性能和内存方面都非常容易:

截图

最后,输入Stat AICrowd会给我们关于当前阶段处理的群众的信息。由于在这个示例中我们没有使用群众,因此该类别为空:

截图

当然,如果你需要同时跟踪多个类别,你可以通过插入控制台命令来实现,它们会一起堆叠,如图所示:

截图

创建自定义统计组

如果你正在编写复杂的 AI,你可能想跟踪更具体的函数及其性能。当然,这不仅仅对 AI 编程有用,对你的游戏任何部分都很有用。虚幻引擎提供了一些简单的宏,可以添加到你的 C++代码中,以便快速输出这些函数的性能统计信息以进行检查。

要创建一个自定义状态组,你需要在头文件中声明它(或者如果你的系统使用继承,你可以在头文件的最高级别声明它,这样相同的统计组就可以供所有继承自这个类的类使用):

DECLARE_STATS_GROUP(TEXT("CustomStatGroupName"), STATGROUP_CustomStatGroupName, STATCAT_Advanced);

然后,在包含你想要跟踪的函数的类的头文件(.h)内部,我们需要添加这个宏(每个需要跟踪的函数一个):

DECLARE_CYCLE_STAT(TEXT("Name of how you want to display this function"), STAT_NameOfTheFunction, STATGROUP_CustomStatGroupName);

最后,在包含我们想要跟踪的函数的实现(.cpp)文件中,我们需要在函数的开始处添加这个宏:

SCOPE_CYCLE_COUNTER(STAT_NameOfTheFunction);

让我们从实际例子开始,这样你可以更好地了解它是如何工作的。我将创建一个简单的演员,在这个演员内部创建状态组,并开始跟踪其tick函数的性能。

让我们创建一个新的从Actor继承的 C++类:

我们可以将其重命名为***TestingStatActor***并将其放置在Chapter12 文件夹中:

接下来,在其头文件(.h)中,我们需要声明统计组(在包含语句下面):

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TestingStatActor.generated.h"

DECLARE_STATS_GROUP(TEXT("AI_MyCustomGroup"), STATGROUP_AI_MyCustomGroup, STATCAT_Advanced);

然后,因为我们想要跟踪这个类中的函数,我们可以在上一行下面声明跟踪函数的意图:

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TestingStatActor.generated.h"

DECLARE_STATS_GROUP(TEXT("AI_MyCustomGroup"), STATGROUP_AI_MyCustomGroup, STATCAT_Advanced);
DECLARE_CYCLE_STAT(TEXT("StatTestActor ~ PerformTick"), STAT_PerformTick, STATGROUP_AI_MyCustomGroup);

最后,在 C++文件中,我们可以在Tick函数的开始处添加以下宏(如果你想要跟踪Super::Tick()部分,甚至可以在它之前),也许我们还可以添加一个日志(这是一个繁重的任务,尤其是对于Tick函数,这样我们可以更好地看到其性能的峰值):

void ATestingStatActor::Tick(float DeltaTime)
{
 SCOPE_CYCLE_COUNTER(STAT_PerformTick);
  Super::Tick(DeltaTime);

  UE_LOG(LogTemp, Warning, TEXT("Test Message on Tick"));
}

现在你可以编译你的代码了,当编译完成后,你可以直接将TestingStatActor拖入场景(记住,它没有场景组件,所以它存在于场景中,但不能定位)。

如果我们在控制台中输入,我们现在能够访问我们的AI_MyCustomGroup

如果我们启用它,我们就能在屏幕上检查游戏中每个 TestingStatActor 的Tick函数的性能(在这个例子中,只有一个):

这就完成了自定义状态组的创建。这确实是一个非常强大的工具,它允许你快速开始对 C++函数进行性能分析。

会话前端

会话前端是虚幻引擎中一个非常强大的性能分析工具。它允许你检查游戏特定部分的表现,记录和保存性能分析会话,以及更多(包括自动测试!在这本书中我们不会涉及这些)。

你可以通过从顶部菜单窗口 | 开发者工具 | 会话前端导航来激活它,如下面的屏幕截图所示:

图片

一旦打开,它看起来是这样的(它应该在控制台标签页中):

图片

控制台标签页。这是控制台标签页的图片。其他(模糊显示)信息在这里并不重要

分析器标签页中,你可以找到所有深入分析所需的信息。

当你开始分析时,这里你会找到更多关于 AI 性能的信息(实际上,你可以找到你游戏每个部分的表现)。例如,在下一张屏幕截图中,你可以看到我正在分析一些 AI 系统:

图片

分析器标签页。这是分析器标签页的图片。其他(模糊显示)信息在这里并不重要

如果你之前创建了一个自定义统计组,你将能够在会话前端对其进行性能分析!所以,请记住,创建一个统计组非常重要,因为稍后你将需要检查你系统的性能。

不幸的是,我们没有时间探索会话前端工具,因为它需要整整一章的内容,并且超出了本书的范围(因为它需要深入挖掘性能分析)。然而,我提到这个工具不仅仅是因为它非常重要,还因为你应该绝对了解它的存在,并且值得你自己进一步探索。实际上,你可以在官方文档中找到更多关于这个工具的信息:docs.unrealengine.com/en-us/Engine/Performance/Profiler,它为学习更多关于这个工具提供了良好的起点。

摘要

在本章中,我们探索了我们 AI 的一些调试工具。当然,这并不全面,还有更多我们没有涉及的内容。然而,我们了解了最重要的工具及其使用方法。

尤其是进一步探索了 EQS 测试兵可用的选项,以及它们如何帮助我们可视化环境查询的运行。我们还了解了如何使用 EQS Profiler 来识别我们的环境查询性能,并深入研究了那些需要优化的部分。

我们还更详细地查看导航系统的显示设置,以便更好地了解我们的导航网格是如何生成的。此外,我们还讨论了导航测试演员,它对于直观查询导航系统并快速获得关于路径查找器性能的反馈非常有用;但我们没有时间详细说明可用的选项。

最后,我们学习了更多关于分析我们的游戏 AI 的方法,尤其是在控制台中使用stat命令。实际上,我们已经探讨了内置的 stat 组以及如何创建一个自定义的组。我们还提到了会话前端,这是分析我们的游戏的一个强大工具。

在下一章中,我们将介绍游戏玩法调试器,这是调试 AI 的另一个重要工具。

第十三章:人工智能调试方法 - 游戏调试器

在本章中,我们将面对一个强大的调试工具。它如此强大,以至于为它单独设立一章是值得的,它是任何 Unreal 引擎中人工智能开发者的最佳拍档。实际上,它是任何 Unreal 开发者的最佳拍档,因为它可以有不同的用途,尤其是在涉及游戏方面(尽管到目前为止它主要被用于人工智能)。

我们将探索游戏调试器(正如官方文档中所述),但有时人们或书籍使用视觉调试器来指代它。我认为它被称为游戏调试器的原因是因为这个工具具有高度抽象化来调试任何游戏方面(包括人工智能)。然而,游戏调试器的内置类别与人工智能相关,这也是它被包含在这本书中的原因。

不要将视觉记录器与游戏调试器混淆,后者是视觉调试器!!

我们将特别介绍以下主题:

  • 探索游戏调试器的解剖结构

  • 了解游戏调试器的扩展和类别

  • 理解每个类别显示的信息类型

  • 通过创建一个新的插件来创建自定义模块(我们需要这个来扩展游戏调试器

  • 通过添加新的类别扩展游戏调试器

  • 通过添加新的扩展扩展游戏调试器

这是本书最后一章之前的最后一个技术部分,我们将更广泛地探讨游戏人工智能。因此,无需多言,让我们直接进入正题!

游戏调试器的解剖结构

当游戏运行时,你可以通过按“'”(撇号)键打开游戏调试器(或视觉调试器)。

所有视觉调试器的快捷键都可以更改/自定义。我们将在本章后面的项目设置部分看到如何更改它们。

游戏调试器分为两部分:扩展类别

  • 扩展是触发特定功能的特定快捷键(切换)。

  • 类别是可切换的信息块,它出现在屏幕上(以及 3D 空间中)与特定系统相关

在屏幕上,游戏调试器在视觉上分为两部分:

图片

顶部部分是控制部分,显示哪些选项可用。特别是,它显示了哪些扩展可用,并突出显示底部部分显示的活动的类别

图片

而底部部分,则显示每个选定类别的不同信息。以下是一些类别的示例:

图片

游戏调试器扩展

如您在以下屏幕截图中所见,游戏调试器只有两个默认扩展和一个内置扩展:

图片

以下为默认扩展和内置扩展:

  • 观众扩展允许你在游戏运行时(游戏进行时)将控制权从玩家角色分离出来,并控制一个观众角色,这样你就可以自由地在关卡上飞行并拥有外部视角。任何时候,你都可以通过切换观众扩展或关闭游戏调试器来重新获得对玩家角色的控制权。切换观众扩展的默认键是Tab键。

  • HUD 扩展允许你切换HUD的开启和关闭(特别是包含在游戏模式实例中的HUD类)。切换HUD 扩展的默认键是Ctrl + Tilde

  • 调试消息内置扩展,正如其名称所示,它切换调试消息。默认键是Ctrl + Tab

游戏调试器类别

游戏调试器被分为不同的类别,可以通过使用键盘(或数字键盘)来启用和禁用(而不仅仅是键盘上的数字)。

如果你没有键盘/数字键盘(例如,你正在使用小型笔记本电脑),在本章的后面,你将找到游戏调试器的设置,你可以更改键绑定,使其与你的键盘相匹配。

类别旁边的数字表示其默认位置(以及需要在键盘上按下的数字以激活它)。然而,这可以在设置中稍后更改。

为了探索类别,我创建了一个简单的测试地图,其中应该包含一些内容,这样我们就可以看到所有游戏调试器类别在实际操作中的表现。这个测试地图包含在本书的关联项目文件中。

类别 0 – 导航网格

第一个类别是导航网格,默认分配给“0”键。

一旦切换,你将能够直接在地图上看到导航网格——就这么简单。当你需要实时检查导航网格时,这非常有用,尤其是如果你有动态障碍物,那么导航网格将在运行时重建。

当此类别启用时,它看起来是这样的:

图片

这是输出截图。其他(模糊显示)的信息在这里并不重要

类别 1 – AI

此类别一旦启用,就会显示有关所选 AI 的大量信息。默认情况下,它分配给“1”键。

如果没有选择演员,这个类别将不会显示任何信息。然而,它将突出显示具有其隶属关系的可用 AI(在 3D 空间中)。

当切换类别(并选择调试演员)时,它看起来如下:

图片

这是输出截图。其他(模糊显示)的信息在这里并不重要

在此类别中,地图上的所有 AI 及其所属关系(在 3D 空间中)都会显示出来,并且选定的调试演员也显示了控制器的名称(始终在 3D 空间中)。然而,直接显示在屏幕上的信息是单个调试演员的信息。

以下是该类别显示的信息类型(带有类别信息的特写):

  • 控制器名称:此部分显示拥有此PawnAI 控制器的名称。

  • Pawn 名称:此部分显示当前被AI拥有的Pawn的名称。

  • 移动模式:如果 Pawn 附加了角色移动组件,则此部分显示当前的移动模式(例如行走、跑步、游泳、飞行、下落等…)

  • 基础:如果 Pawn 附加了角色移动组件,则此部分显示角色站立的基础。在行走或跑步的情况下,这是 AI 当前行走或跑步的地面网格。在下落的情况下,这是“”。

  • NavData:此部分显示 AI 当前正在使用的NavData。最可能的情况是,值将是“默认”,除非您通过 C++为 AI 角色指定了特定的NavData

  • 路径跟随:当 AI 角色移动时,此部分显示要跟随的路径的状态。还会显示诸如点积2D 距离Z 距离等信息。以下是一个角色移动时的示例:

  • 行为:此部分指示是否有行为正在运行(例如,此AI 控制器上是否正在运行行为树?)。

  • :此部分指示 AI 当前正在运行的行为树(如果正在运行行为)。

  • 活动任务:此部分指示当前正在执行的行为树任务,以及任务编号(该任务在树中的顺序编号)。

有关行为树类别中当前任务的更多信息,请参阅下一节。

  • 游戏任务:此部分显示当前分配给此 AI 的游戏任务数量。

  • 蒙太奇:此部分显示角色当前正在播放的蒙太奇(如果有的话)。

尽管我们在这本书中没有涉及这个话题,但同步 AI 动作与动画是 AI 程序员和动画师之间的中间地带。

值得注意的是,如果 AI 正在移动,即使导航网格类别没有切换,它也会显示 AI 当前用于导航的导航网格的一部分,如下面的截图所示:

这是输出截图。其他(模糊)信息在此处不重要。

类别 2 – 行为树

此类别显示关于当前正在 AI 上运行的行为树的信息。默认情况下,它被分配给“2”键。

如果没有运行 行为树,则此部分将不会显示任何内容。

当激活时,行为树分类 看起来如下:

图片

这是一张输出截图。其他(模糊处理)的信息在这里并不重要

这个分类仅显示屏幕上的信息(所以在 3D 空间中没有显示)。特别是,它在左侧显示了以下信息:

图片

  • 大脑组件:这显示了当前 AI 控制器正在使用的哪种类型的 大脑组件,它将是 BTComponent 类型。

由于 Unreal 是以 模块化 为目标开发的,所以任何可以包含 AI 逻辑的东西都可以称为 大脑组件。在撰写本文时,唯一的内置 大脑组件行为树 (BTComponent)。

  • 行为树:这是 AI 正在使用的 行为树 的名称。

  • 任务树:在 行为树 属性之后,显示了当前正在执行的所有任务分支。这是从根节点(包含所有节点名称及其相应的编号)到 AI 正在执行的任务的路径。

这非常有用,当你需要了解为什么选择了特定的任务,而不是另一个任务,可以通过沿着树路径跟踪来理解。

在右侧,相反,显示了正在使用的 黑板 资产的名称。在此之下,是正在使用的 黑板 的键及其当前值:

图片

以下示例显示了两个 黑板键目的地Self Actor。尝试在 设计行为树项目 中测试 游戏调试器,以查看更多内容并获得更好的感觉,因为你已经从零开始构建了这些结构。以下是你将看到的一瞥:

图片

这是一张输出截图。其他(模糊处理)的信息在这里并不重要

当然,当你想要测试 黑板 中是否设置了正确的值时,这非常有用。

这里有一个更多示例,展示了角色移动的情况:

图片

这是一张输出截图。其他(模糊处理)的信息在这里并不重要

分类 3 – EQS

这个分类显示了 AI 当前正在执行的 环境查询。默认情况下,它被分配到 "3" 键。

如果 AI 没有执行任何 环境查询,那么这个分类将只显示查询数量为零。

EQS 分类 被激活时,屏幕上会得到以下输出:

图片

这是一张输出截图。其他(模糊处理)的信息在这里并不重要

从先前的截图中,我们可以看到这个类别突出了查询生成的不同点及其得分。根据查询的运行模式,可以看到哪个点是获胜者(它有最高的得分,其颜色比其他颜色更亮)。

此外,一个点上面的红色箭头表示它已被选中(这意味着它是你正在查看的最近的一个点)。这很有用,因为在侧面的信息显示中,你可以检查这个特定的点在排行榜上的排名位置。

在旁边,你可以找到关于查询的一些额外信息:

特别是,以下信息被显示:

  • 查询:这是调试演员正在运行的查询数量。

  • 查询名称和运行模式:这显示了哪个查询已被(或目前正在执行)。然后,在下划线之后,它显示了运行模式(在先前的截图中,它是单个结果)。

  • 时间戳:这是查询被执行的时间戳,以及它发生的时间。

  • 选项:这显示了查询的生成器

  • 所选项目:这显示了所选项目在排行榜中的位置/排名。在先前的截图中,我们选择的项目在排行榜上是第 11 位(从全屏截图可以看到,它的得分为1.31,而获胜点的得分为2.00)。这对于检查你正在查看的点是如何排名的非常有用,因为它能快速给出这些点之间相对分数的概念。

请记住,当一个点被排名时,排名从零开始,因此获胜点排名为 0。所以,在先前的截图中,"所选项目:11"意味着它在排行榜上是第 11 位,但它是在列表中的第 12 个点。

为了方便起见,这里还有一个例子,其中所选点是获胜点(注意它的排名是 0):

这是一张输出结果的截图。其他(被模糊处理)的信息在这里并不重要

类别 4 – 感知

这个类别显示了关于所选AI 代理感知信息。默认情况下,它被分配给"4"键,也就是说,除非"导航网格"类别被启用;在这种情况下,默认键是"5"。

如果没有选择任何演员,这个类别不会显示任何内容。

当激活时,感知类别显示如下:

这是一张输出结果的截图。其他(被模糊处理)的信息在这里并不重要

在屏幕上,这个类别显示了所有已实现的感官,以及它们的调试颜色。然后,每个感官可以根据其DescribeSelfToGameplayDebugger()函数的实现显示额外的信息。例如,在视觉的情况下,有RangeInRangeOut的调试颜色,如下面的截图所示:

图片

在关卡中,您将能够看到特定感官的刺激物以球形呈现(包括感官名称、刺激强度和刺激物的年龄,当为视觉时年龄为零)。然后,有一条线连接到每个刺激物,如果目标不在视线范围内,还有一条线连接到单个刺激物和目标(例如玩家)。这是在视觉情况下的显示方式:

图片

这是一张输出截图。其他(被模糊处理)的信息在这里并不重要。

为了展示当目标(例如玩家)不在视线范围内时的情况,因此刺激物的年龄大于零,并且可以看到连接刺激物到目标的黑色线条,这里还有另一张截图:

图片

这是一张输出截图。其他(被模糊处理)的信息在这里并不重要。

如果我们还要添加听觉感官,这将是这样显示的:

图片

这是一张输出截图。其他(被模糊处理)的信息在这里并不重要。

请注意,听觉感官(黄色)在视觉感官的不同级别(z 轴)上显示。因此,即使我们具有相同的值,例如在前面的截图中,两者都有 1500 的范围,它们也会很好地叠加。

当然,侧面的信息提供了更多关于在游戏世界中显示的调试颜色的信息:

图片

导航网格类别

根据您的设置,您可能已经启用了导航网格类别,这与导航网格类别不同。

这个类别应该处理网格移动,这是我们在这本书中没有涉及到的。然而,如果您在我们的示例地图中激活这个类别,它只会显示源的数量为零:

图片

屏幕上的多个类别

我们已经看到了每个类别是如何单独表现的。然而,为了明确起见,您可以在显示上拥有尽可能多的类别。这意味着您可以同时显示多个类别。实际上,通常您需要同时看到多个系统:

图片

这是一张输出截图。其他(被模糊处理)的信息在这里并不重要。

我个人非常喜欢 Gameplay Debugger 的一个地方是,一旦您掌握了它,即使有这么多 Categories 打开,信息也不会使屏幕显得拥挤,并且显示得很好。

更多类别

虽然看起来我们已经走过了所有不同的 Categories,但实际上我们还没有。事实上,引擎中内置了一些额外的 Gameplay Debugger Categories,例如与 HTN PlannerAbility System 相关的类别。

很遗憾,它们超出了本书的范围,但您可以在 C++ 中搜索它们。您可以通过在 Engine Source 中搜索 GameplayDebuggerCategory 来开始您的搜索,以了解更多相关信息。

Gameplay Debugger 设置

正如我们之前提到的,您可以通过更改其设置来配置 Gameplay Debugger

如果您导航到 Project Settings,您可能会找到一个专门针对 Gameplay Debugger 的部分,如下面的截图所示:

Input 标签允许您覆盖打开和关闭 Gameplay Debugger(默认是 " '" 引号键)以及触发不同类别(默认为 keypad/numpad 上的 0 到 9 的数字)的默认键:

Display 标签允许您定义一些填充,以便您可以显示有关 Gameplay Debugger 的信息。通过这样做,您不需要将其附加到屏幕上。默认值都是 10

Add-Ons tab 允许您为 Categories(当类别默认启用时,以及它关联的键/数字)和 Extension(覆盖它们的输入键)配置单个设置:

对于 Category 的 "-1" 值意味着该 number/position/key 已由编辑器分配,因为此 Category 没有在屏幕上的 "preference" 位置。

扩展 Gameplay Debugger

到目前为止,我们已经看到所有不同的 Gameplay Debugger 类别如何帮助我们理解我们的 AI Character 的行为。然而,如果我们能有一个自己的类别来可视化我们为游戏开发的定制(子)系统的数据,那岂不是很棒?

答案是肯定的,本节将解释如何做到这一点。

请记住,这个工具被称为 Gameplay Debugger,因此您不仅可以扩展它用于 AI,还可以用于游戏中的任何事物,特别是与 Gameplay 相关的事物(因为它是一个实时工具,用于可视化信息)。到目前为止,它已被广泛用于 AI,但它有潜力用于其他任何事物!

正如我们已经看到的,Gameplay Debugger 被分为 CategoriesExtensions

首先,我们将更详细地探讨如何创建一个自定义类别,从为它创建一个独立的模块开始,包括我们需要的所有依赖项和编译器指令。我们将看到我们如何创建控制类别的类,以及我们如何将其注册到游戏调试器。结果,我们将拥有一个完全功能的游戏调试类别,它将在屏幕上打印我们的调试 Actor的位置:

图片

最后,我们将探讨如何为游戏调试器创建一个自定义扩展,当按下特定键时,它将能够打印玩家的位置。

有了这些,让我们开始创建一个新的插件

使用新插件创建模块

要通过一个新的类别扩展游戏调试器,您需要在您的游戏中创建一个新的模块。实际上,引擎是由不同的模块组成的,您的游戏也是如此(通常,游戏只是一个模块,尤其是如果游戏很小;在您使用 C++开始一个新项目时,游戏只是一个模块,所以如果您需要,您将需要添加更多)。

我们有几种创建模块的方法,我不会深入讲解模块的工作原理以及如何为您的项目创建一个模块。相反,我将指导您如何设置一个用于运行新的游戏调试类别的自定义模块。

创建另一个模块最简单的方法是创建一个插件。因此,代码被从我们的游戏的其他部分分离出来,这既有好的一面也有不好的一面。然而,我们不会在本节中讨论这一点。相反,我将向您展示如何创建一个自定义的游戏调试类别,然后您可以根据自己的需求进行适配。

让我们从打开插件菜单开始,从视口顶部的设置菜单按钮,如下面的截图所示:

图片

一旦打开插件窗口,您需要点击右下角的“新建插件”按钮:

图片

这不是创建插件的唯一方法,但这是最快的,因为 Unreal 包含一个简单的向导来创建不同模板的插件

因此,我们将打开新插件窗口,这是一个用于创建新插件的向导:

图片

我们需要选择空白模板(因为我们只想加载一个基本的模块)。然后,我们可以填写名称,在我们的例子中是GameplayDebugger_Locator。接下来,有输入字段需要填写您的插件:作者描述。我将自己作为作者,在描述中插入"一个用于可视化 Actor 位置的定制游戏调试类别"。这就是现在屏幕应该看起来像的样子:

图片

点击创建插件,我们的插件将被创建。这可能需要一些时间来处理,所以请耐心等待:

图片

一旦编译完成,你将拥有 Plugin 的基本结构和代码作为一个单独的模块。你可以在 Visual Studio 中查看它。在 Plugins 文件夹下,你应该有以下结构:

图片

此外,如果你回到 Plugin 窗口,你将能够看到我们的 Plugin(并确保它已启用):

图片

当然,你可以自由地 "编辑" 插件,例如,更改其图标或类别。

设置模块以与 Gameplay Debugger 一起工作

在我们为 Gameplay Debugger 的新类别添加代码之前,有一些考虑事项需要考虑。

首先,正如其名所示,Gameplay Debugger 是一个调试工具。这意味着它不应该与游戏一起发布。因此,如果我们正在编译一个发布版本的游戏,我们需要一种方法来去除所有与 Gameplay Debugger 相关的代码。当然,我们正在创建的 Plugin 只包含 Gameplay Debugger 的代码,但在你的游戏中,它更有可能存在于一个更广泛的环境中。

要去除代码,你需要违反一个可以与编译宏一起使用的编译变量;然而,我们只想在游戏未发布时将此变量定义为真(值等于一)。为了实现这一点,我们需要导航到我们的插件 .build.cs 文件。在我们的例子中,它被称为 GameplayDebugger_Locator.build.cs,你可以在 Visual Studio(或你选择的代码编辑器)中找到它,位于我们的 Plugin 文件夹的层次结构中。实际上,Unreal 在编译前运行一些工具(例如,生成反射代码和替换 C++ 代码中的宏),这些工具是用 C# 编写的。因此,我们可以用一段 C# 代码来修改它们的行为。

一旦打开文件,你将找到一个函数,该函数定义了模块的不同依赖项。在此函数的末尾添加以下代码:

        //Code added for a Custom Category of Gameplay Debugger
        if (Target.bBuildDeveloperTools || (Target.Configuration != UnrealTargetConfiguration.Shipping && Target.Configuration != UnrealTargetConfiguration.Test)) {
            PrivateDependencyModuleNames.Add("GameplayDebugger");
            Definitions.Add("WITH_GAMEPLAY_DEBUGGER=1");
        } else {
            Definitions.Add("WITH_GAMEPLAY_DEBUGGER=0");
        }

这是一个检查 BuildDeveloperTools 是否为真或目标配置(我们将用其编译 C++ 代码的配置)是否不同于 ShippingTest 的 if 语句。如果这个条件得到验证,那么我们为这个模块添加一个 Private Dependency,即 GameplayDebugger 模块,并将 WITH_GAMEPLAY_DEBUGGER 变量定义为真(用于编译 C++ 代码)。否则,我们只声明 WITH_GAMEPLAY_DEBUGGER 变量为假。

因此,我们能够使用编译器指令中的 WITH_GAMEPLAY_DEBUGGER 变量来包含或排除(取决于我们正在构建哪种配置)与 游戏调试器 相关的特定代码。所以,从现在开始,当我们为我们的 游戏调试器 类别编写代码时,不要忘记将其包裹在以下编译器指令中:

#if WITH_GAMEPLAY_DEBUGGER
    //[CODE]
#endif

创建一个新的游戏调试器类别

下一步是为我们的 游戏调试器类别 创建一个新的类。

如同往常,我们可以创建一个新的 C++ 类,但这次,我们将选择 None 作为父类(我们将自己编写类并手动实现继承):

然后,我们可以将其重命名为 GameplayDebuggerCategory_Locator(遵循以 GameplayDebuggerCategory_ 开头类名的约定,后跟 类别名称)。现在,请小心选择正确的模块;在模块名称旁边,你可以选择该类所属的模块。到目前为止,我们一直只使用一个模块,所以没有这个问题。你需要选择 GameplayDebugger_Locator (Runtime) 模块,如下面的截图所示:

创建类,并等待它被添加到我们的 插件 中。

现在,是时候开始积极创建我们的类了。进入我们新创建的类的头文件(.h 文件)并删除所有内容。我们将首先包含引擎最小核心,然后在 #if WITH_GAMEPLAY_DEBUGGER 编译器指令中,我们还将包含 GameplayDebuggerCategory.h 文件,因为它是我们的父类:

#pragma once

#include "CoreMinimal.h"

#if WITH_GAMEPLAY_DEBUGGER

#include "GameplayDebuggerCategory.h"

*//[REST OF THE CODE]*

#endif

然后,我们需要创建类本身。遵循约定,我们可以将类重命名为与文件名相同的名称,FGameplayDebuggerCategory_Locator,并使其继承自 FGameplayDebuggerCategory

class FGameplayDebuggerCategory_Locator : public FGameplayDebuggerCategory
{
 *//[REST OF THE CODE]*
};

游戏调试器 是一个强大的工具,因此它具有许多功能。其中之一是它支持复制的功能。因此,我们需要设置一个支持该功能的结构。如果你打开其他 游戏调试器类别 的源文件(来自引擎),你会看到它们遵循声明一个名为 FRepData 的受保护结构的约定。在这个结构中,我们声明了所有我们需要可视化的变量。在我们的例子中,我们只需要一个字符串,我们将称之为 ActorLocationString。此外,这个结构需要有序列化的方式,因此我们需要添加 void Serialize(FArchive& Ar) 函数,或者至少它的声明。最后,我们可以在 "受保护" 下创建一个名为 DataPackFRepData 类型的变量,如下面的代码所示:

protected:
  struct FRepData
  {
    FString ActorLocationString;

    void Serialize(FArchive& Ar);
  };

  FRepData DataPack;

接下来,我们需要重写一些公共函数以使我们的类别工作。这些函数如下:

  • 构造函数: 这设置了类的初始参数,并将为 DataPack 设置数据复制。

  • MakeInstance(): 这将创建此类的一个实例(使用共享引用)。当我们稍后注册我们的类别时,Gameplay Debugger 需要这个操作(这意味着我们将将其添加到编辑器中)。

  • CollectData(): 这收集并存储我们想要显示的数据,然后将其存储在 DataPack(可以复制)中。它作为输入(以便我们可以使用它),Player Controller,以及 DebugActor(如果可用),这是我们已在 Gameplay Debugger 中设置的焦点 Actor(记住,当我们分析特定角色的行为时,我们选择了特定的角色;在这里,幕后,它作为参数传递给 CollectData() 函数)。

  • DrawData(): 这将在屏幕上显示数据;我们将使用 DataPack 变量来检索在 CollectData() 函数中收集的数据。它作为输入(以便我们可以使用它),Player Controller,以及 CanvasContext 提供,这是我们将在屏幕上实际显示数据的工具。

现在,我们可以在我们的头文件(.h)文件中声明它们:

public:

  FGameplayDebuggerCategory_Locator();

  static TSharedRef<FGameplayDebuggerCategory> MakeInstance();

  virtual void CollectData(APlayerController* OwnerPC, AActor* DebugActor) override;

  virtual void DrawData(APlayerController* OwnerPC, FGameplayDebuggerCanvasContext& CanvasContext) override;

这就完成了我们在头文件(.h)文件中需要的内容。为了方便起见,以下是头文件(.h)文件的完整代码:

#pragma once
#include "CoreMinimal.h"
#if WITH_GAMEPLAY_DEBUGGER
#include "GameplayDebuggerCategory.h"
class FGameplayDebuggerCategory_Locator : public FGameplayDebuggerCategory
{
protected:
  struct FRepData
  {
    FString ActorLocationString;
    void Serialize(FArchive& Ar);
  };
  FRepData DataPack;
public:
  FGameplayDebuggerCategory_Locator();
  static TSharedRef<FGameplayDebuggerCategory> MakeInstance();
  virtual void CollectData(APlayerController* OwnerPC, AActor* DebugActor) override;
  virtual void DrawData(APlayerController* OwnerPC, FGameplayDebuggerCanvasContext& CanvasContext) override;
};
#endif

下一步是编写实现。因此,打开 .cpp 文件,如果尚未这样做,清除所有内容,以便你可以从头开始。

再次,我们需要包含一些头文件。当然,我们需要包含我们自己的类头文件(我们刚刚编辑的头文件)。然后,在 #if WITH_GAMEPLAY_DEBUGGER 编译器指令下,我们需要包含 Actor 类,因为我们需要检索 Actor 的位置:

#include "GameplayDebuggerCategory_Locator.h"

#if WITH_GAMEPLAY_DEBUGGER
#include "GameFramework/Actor.h"

*//[REST OF THE CODE]*

#endif

现在,我们可以开始实现所有我们的函数。我们将从主类的 构造函数 开始。在这里,我们可以设置 Gameplay Debugger Category 的默认参数。

例如,我们可以将 bShowOnlyWithDebugActor 设置为 false,正如其名称所暗示的,这允许即使我们没有选择 Debug Actor,此类别也可以显示。实际上,即使我们的 Category 需要使用 DebugActor 来显示其位置,我们仍然可以打印其他信息(在我们的情况下,我们将进行简单的打印)。当然,当你创建你的类别时,你可以决定这个布尔值是否为真。

然而,更重要的是通过 SetDataPackReplication<FRepData>(&DataPack) 函数设置我们的 DataPack 变量以进行复制:

FGameplayDebuggerCategory_Locator::FGameplayDebuggerCategory_Locator()
{
  bShowOnlyWithDebugActor = false;
  SetDataPackReplication<FRepData>(&DataPack);
}

接下来,我们需要实现我们的 Serialize() 函数,用于我们的 RepData 结构。由于我们只有一个字符串,其实现相当简单;我们只需要将 String 插入到 Archive 中:

void FGameplayDebuggerCategory_Locator::FRepData::Serialize(FArchive& Ar) {
  Ar << ActorLocationString;
}

要将这个类别注册到游戏调试中,我们必须实现MakeInstance()函数,该函数将返回一个对这种类别实例的共享引用。因此,这里的代码也很简单;只需创建一个新的实例作为共享引用并返回值:

TSharedRef<FGameplayDebuggerCategory> FGameplayDebuggerCategory_Locator::MakeInstance()
{
  return MakeShareable(new FGameplayDebuggerCategory_Locator());
}

我们还有两个函数需要实现。前者收集数据,而后者显示数据。

CollectData()函数已经将调试演员作为参数传递。因此,在我们验证引用有效后,我们可以检索调试演员的位置并将其分配到包含在DataPack变量中的FRepData结构体内部的ActorLocationString变量中。这比解释更容易展示:

void FGameplayDebuggerCategory_Locator::CollectData(APlayerController * OwnerPC, AActor * DebugActor)
{
  if (DebugActor) {
    DataPack.ActorLocationString = DebugActor->GetActorLocation().ToString();
  }
}

当然,在CollectData()函数中,你可以运行任何逻辑来检索你自己的数据。只需记住将其存储在DataPack变量中,它是FRepData结构的指针,它可以像你喜欢的那么复杂(并且记得也要序列化它)。

最后,DrawData()函数负责实际显示我们收集到的信息。特别是,我们有一个对画布上下文的引用,我们将用它来"打印"信息。我们甚至有一些格式化选项,例如通过在文本前加上"{颜色}"来给文本上色。

首先,我们将打印一些文本,然后打印调试演员的位置(如果有的话)。我们也会使用颜色,所以让我们了解一下如何使用它们:

void FGameplayDebuggerCategory_Locator::DrawData(APlayerController * OwnerPC, FGameplayDebuggerCanvasContext & CanvasContext)
{
  CanvasContext.Printf(TEXT("If a DebugActor is selected, here below is its location:"));
  CanvasContext.Printf(TEXT("{cyan}Location: {yellow}%s"), *DataPack.ActorLocationString);
}

这是我们实现(.cpp)文件中的最后一个函数。为了方便起见,这里是有整个文件的内容:

#include "GameplayDebuggerCategory_Locator.h"

#if WITH_GAMEPLAY_DEBUGGER
#include "GameFramework/Actor.h"

FGameplayDebuggerCategory_Locator::FGameplayDebuggerCategory_Locator()
{
  bShowOnlyWithDebugActor = false;
  SetDataPackReplication<FRepData>(&DataPack);
}

void FGameplayDebuggerCategory_Locator::FRepData::Serialize(FArchive& Ar) {
  Ar << ActorLocationString;
}

TSharedRef<FGameplayDebuggerCategory> FGameplayDebuggerCategory_Locator::MakeInstance()
{
  return MakeShareable(new FGameplayDebuggerCategory_Locator());
}

void FGameplayDebuggerCategory_Locator::CollectData(APlayerController * OwnerPC, AActor * DebugActor)
{
  if (DebugActor) {
    DataPack.ActorLocationString = DebugActor->GetActorLocation().ToString();
  }
}

void FGameplayDebuggerCategory_Locator::DrawData(APlayerController * OwnerPC, FGameplayDebuggerCanvasContext & CanvasContext)
{
  CanvasContext.Printf(TEXT("If a DebugActor is selected, here below is its location:"));
  CanvasContext.Printf(TEXT("{cyan}Location: {yellow}%s"), *DataPack.ActorLocationString);
}

#endif

现在,我们有了游戏调试类别,但我们需要将其注册游戏调试中。所以,无需多言,让我们直接进入下一节。

注册游戏调试类别

在上一节中,我们创建了一个游戏调试类别,但现在我们需要将其注册游戏调试中。

做这件事的最简单方法是在我们模块的StartupModule()函数内部注册类别,所以让我们打开GameplayDebugger_Locator.cpp文件。

我们需要做的第一件事是包含游戏调试模块,以及我们创建的游戏调试类别。我们需要用#if WITH_GAMEPLAY_DEBUGGER编译指令将#include语句包围起来,如下面的代码所示:

#if WITH_GAMEPLAY_DEBUGGER
#include "GameplayDebugger.h"
#include "GameplayDebuggerCategory_Locator.h"
#endif

StartupModule()函数内部,我们需要检查游戏调试器模块是否可用,如果可用,则检索其引用。然后,我们可以使用这个引用通过RegisterCategory()函数注册我们的类别,该函数接受三个参数(类别的名称、创建类别实例的函数的引用以及一些枚举选项)。最后,我们需要通知更改。当然,再次强调,此代码由#if WITH_GAMEPLAY_DEBUGGER编译器指令包装:

void FGameplayDebugger_LocatorModule::StartupModule()
{

#if WITH_GAMEPLAY_DEBUGGER

  if (IGameplayDebugger::IsAvailable())
  {
    IGameplayDebugger& GameplayDebugger = IGameplayDebugger::Get();

    GameplayDebugger.RegisterCategory("Locator", IGameplayDebugger::FOnGetCategory::CreateStatic(&FGameplayDebuggerCategory_Locator::MakeInstance), EGameplayDebuggerCategoryState::EnabledInGameAndSimulate);

    GameplayDebugger.NotifyCategoriesChanged();
  }

#endif
}

到目前为止,一切顺利,但当我们在一个模块中注册某些内容时,我们还需要在模块关闭时“注销”。因此,在ShutdownModule()函数中,我们需要执行与之前相同的步骤,但这次注销类别。首先,我们需要检查游戏调试器模块的有效性,然后检索它,注销类别,并通知更改。同样,代码再次由#if WITH_GAMEPLAY_DEBUGGER编译器指令包装:

void FGameplayDebugger_LocatorModule::ShutdownModule()
{

#if WITH_GAMEPLAY_DEBUGGER

  if (IGameplayDebugger::IsAvailable())
  {
    IGameplayDebugger& GameplayDebugger = IGameplayDebugger::Get();

    GameplayDebugger.UnregisterCategory("Locator");

    GameplayDebugger.NotifyCategoriesChanged();
  }
#endif
}

为了您的方便,以下是文件的完整代码:

#include "GameplayDebugger_Locator.h"

#if WITH_GAMEPLAY_DEBUGGER
#include "GameplayDebugger.h"
#include "GameplayDebuggerCategory_Locator.h"
#endif

#define LOCTEXT_NAMESPACE "FGameplayDebugger_LocatorModule"

void FGameplayDebugger_LocatorModule::StartupModule()
{

#if WITH_GAMEPLAY_DEBUGGER

  if (IGameplayDebugger::IsAvailable())
  {
    IGameplayDebugger& GameplayDebugger = IGameplayDebugger::Get();

    GameplayDebugger.RegisterCategory("Locator", IGameplayDebugger::FOnGetCategory::CreateStatic(&FGameplayDebuggerCategory_Locator::MakeInstance), EGameplayDebuggerCategoryState::EnabledInGameAndSimulate);

    GameplayDebugger.NotifyCategoriesChanged();
  }

#endif
}

void FGameplayDebugger_LocatorModule::ShutdownModule()
{

#if WITH_GAMEPLAY_DEBUGGER

  if (IGameplayDebugger::IsAvailable())
  {
    IGameplayDebugger& GameplayDebugger = IGameplayDebugger::Get();

    GameplayDebugger.UnregisterCategory("Locator");

    GameplayDebugger.NotifyCategoriesChanged();
  }
#endif
}

#undef LOCTEXT_NAMESPACE

IMPLEMENT_MODULE(FGameplayDebugger_LocatorModule, GameplayDebugger_Locator)

编译后,我们的代码就准备好了。此外,请确保插件已激活,然后关闭并重新打开编辑器(这样我们可以确保我们的模块已正确加载)。

让我们探索我们在虚幻中创建的内容是如何工作的。

可视化自定义游戏调试器类别

一旦我们重新启动了编辑器,我们的插件也会被加载,这意味着我们的游戏调试器类别也被加载了。要检查这一点,我们可以导航到项目设置下的游戏调试器部分。在这里,我们有所有配置游戏调试器的选项,包括已加载的类别。因此,如果我们向下滚动,我们应该能够找到我们的定位器类别,如下面的截图所示:

图片

如您所见,所有选项都设置为“使用默认设置”,这是我们注册类别时传递第三个参数时设置的。然而,您也可以在这里覆盖它们(例如,确保它始终处于启用状态)。可选地,您可以更改触发此类别的键,或者如果您没有偏好,可以保留默认设置。编辑器将为您分配一个:

图片

如果您在尝试加载带有游戏调试器的插件时遇到困难,您应该从虚幻的顶部菜单导航到窗口 | 开发者工具 | 模块。从这里,搜索我们的定位器模块,然后按如下截图所示按重新加载:

图片

您可能需要每次加载编辑器时都这样做,以便使用您的类别和/或扩展。

现在,如果我们按下播放并激活游戏调试器,我们将看到我们的类别被列出(它可能默认处于激活或非激活状态,具体取决于您之前设置的设置):

图片

如果我们选择另一个演员,我们将能够看到定位类别将显示其位置:

图片

这是一张输出截图。其他(被模糊处理)的信息在这里并不重要。

这里是一个特写镜头:

图片

这就结束了我们对创建自定义游戏调试器类别的讨论。当然,这是一个非常简单的例子,但你很容易想象这种工具的潜力以及它如何在你的项目工作流程中使用。

在我们结束这一章之前,正如我们之前提到的,让我们看看我们如何通过添加一个扩展来扩展游戏调试器

为游戏调试器创建扩展

正如我们之前提到的,游戏调试器类别(我们已经看到了如何创建一个自定义的)和扩展组成。再次强调,创建扩展仅限于 C++。

游戏调试器类别一样,一个扩展需要存在于一个自定义模块上,但它可以是与类别(或类别集)相同的。因此,我将使用我们刚刚开发的相同插件。

特别是,我们将创建一个简单的扩展,当我们按下特定的键时,会在输出日志中打印玩家的位置。

扩展的结构

我们需要创建一个新的 C++类,并从游戏调试器扩展继承(从一个空类开始,就像我们在扩展类别时做的那样,然后在此基础上构建)。我们将在这里使用的命名约定是"GameplayDebuggerExtension_Name"(然而,请记住,文件名可能存在32个字符的限制)。在我们的例子中,我们将选择GameplayDebuggerExtension_Player

图片

游戏调试器扩展的结构非常简单,因为我们需要实现和/或覆盖以下函数:

  • 构造函数:这为扩展设置默认值,包括设置。更重要的是,它为扩展设置键绑定(并传递你希望绑定的函数的引用)。

  • MakeInstance():这创建了一个游戏调试器扩展的实例作为共享引用。当扩展注册时,此函数是必需的。

  • OnActivated():当扩展被激活时执行初始化(例如,游戏调试器打开)。

  • OnDeactivated():当扩展被停用时进行清理(例如,游戏调试器关闭)。例如,观众扩展使用此函数来销毁观众控制器(如果存在)并将控制权返回给之前的玩家控制器

  • GetDescription():这描述了扩展游戏调试器的功能。这意味着该函数返回一个用于在游戏调试器中显示文本的字符串;允许使用带有颜色的常规格式。此外,您可以使用FGameplayDebuggerCanvasStrings::ColorNameEnabled*FGameplayDebuggerCanvasStrings::ColorNameDisabled*来分别描述扩展的启用或禁用颜色。如果您的扩展使用切换功能,这将非常有用。

  • 动作函数:这执行您希望您的扩展执行的操作,因此在这里,它可以是你想要的任何东西。此函数将在构造函数中将传递给输入绑定。

创建扩展类

当然,我们不需要查看的所有函数。在我们的情况下,我们可以在头文件(.h)中声明ConstructorGetDescription()MakeInstance()函数:

public:
  GameplayDebuggerExtension_Player();

  //virtual void OnDeactivated() override;
  virtual FString GetDescription() const override;

  static TSharedRef<FGameplayDebuggerExtension> MakeInstance();

接下来,我们需要一个受保护的函数,我们将将其绑定到特定的输入:

protected:

  void PrintPlayerLocation();

然后,我们需要一些受保护的变量:一个布尔变量用于检查是否已绑定输入,另一个布尔变量用于查看是否已缓存描述,以及一个包含缓存描述本身的变量:

protected:
  uint32 bHasInputBinding : 1;
  mutable uint32 bIsCachedDescriptionValid : 1;
  mutable FString CachedDescription;

为了性能原因,始终缓存游戏调试器扩展的描述是一个好的做法。

当然,不要忘记将整个类包含在条件编译指令和*WITH_GAMEPLAY_DEBUGGER*宏中。这是头文件(.h)应该看起来像的:

#include "CoreMinimal.h"

#if WITH_GAMEPLAY_DEBUGGER
#include "GameplayDebuggerExtension.h"

/**
 * 
 */
class GAMEPLAYDEBUGGER_LOCATOR_API GameplayDebuggerExtension_Player : public FGameplayDebuggerExtension
{
public:
  GameplayDebuggerExtension_Player();

  //virtual void OnDeactivated() override;
  virtual FString GetDescription() const override;

  static TSharedRef<FGameplayDebuggerExtension> MakeInstance();

protected:

  void PrintPlayerLocation();

  uint32 bHasInputBinding : 1;
  mutable uint32 bIsCachedDescriptionValid : 1;
  mutable FString CachedDescription;

};

#endif

对于实现,我们可以从添加以下#include语句开始,因为我们需要访问玩家控制器及其 Pawn 以检索玩家的位置。此外,我们还需要绑定输入,因此需要包含输入核心类型

#include "InputCoreTypes.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Pawn.h"

接下来,我们将实现构造函数。在这里,我们将输入绑定到特定的键。在我们的例子中,我们可以将其绑定到P键。当然,我们需要一个委托,我们可以传递我们的PrintPlayerLocation()函数来完成此操作:

GameplayDebuggerExtension_Player::GameplayDebuggerExtension_Player()
{
  const FGameplayDebuggerInputHandlerConfig KeyConfig(TEXT("PrintPlayer"), EKeys::NumLock.GetFName());
  bHasInputBinding = BindKeyPress(KeyConfig, this, &GameplayDebuggerExtension_Player::PrintPlayerLocation);
}

如我们之前提到的,如果您能的话,缓存您的描述,这样您的扩展就能获得一些性能。以下是缓存我们描述的代码结构:

FString GameplayDebuggerExtension_Player::GetDescription() const
{
  if (!bIsCachedDescriptionValid)
  {
    CachedDescription = *[SOME CODE HERE TO RETRIEVE THE DESCRIPTION]*

    bIsCachedDescriptionValid = true;
  }

  return CachedDescription;
}

现在,我们需要获取描述。在这种情况下,它可以是输入处理程序(这样我们就能记住这个扩展绑定到哪个键,以及单词“玩家”来记住这是一个检索玩家位置的扩展。至于颜色,游戏调试器扩展提供了一些访问特定颜色的快捷方式(例如,对于切换不同类型的扩展,颜色可以根据是否切换而改变)。我们目前不会过多关注颜色,我们将使用默认的颜色,假设一切总是启用的。因此,这是GetDescription()函数:

FString GameplayDebuggerExtension_Player::GetDescription() const
{
  if (!bIsCachedDescriptionValid)
  {
    CachedDescription = !bHasInputBinding ? FString() :
      FString::Printf(TEXT("{%s}%s:{%s}Player"),
        *FGameplayDebuggerCanvasStrings::ColorNameInput,
        *GetInputHandlerDescription(0),
        *FGameplayDebuggerCanvasStrings::ColorNameEnabled);

    bIsCachedDescriptionValid = true;
  }

  return CachedDescription;
}

另一方面,MakeInstance() 函数相当简单,并且非常类似于我们用于 游戏调试器类别 的一个;它只需要返回对这个扩展的共享引用:

TSharedRef<FGameplayDebuggerExtension> GameplayDebuggerExtension_Player::MakeInstance()
{
  return MakeShareable(new GameplayDebuggerExtension_Player());
}

最后,在我们的 PrintPlayerPosition() 函数中,我们只需使用一个 UE_LOG 来打印玩家的位置。然而,在一个 游戏调试器扩展 中,真正的魔法发生在这些(绑定到输入)函数中:

void GameplayDebuggerExtension_Player::PrintPlayerLocation()
{
  UE_LOG(LogTemp, Warning, TEXT("Player's Location: %s"), *GetPlayerController()->GetPawn()->GetActorLocation().ToString());
}

再次提醒,不要忘记用编译器指令包裹你的 C++ 类。

因此,这是我们班级的 .cpp 文件:

#include "GameplayDebuggerExtension_Player.h"

#if WITH_GAMEPLAY_DEBUGGER
#include "InputCoreTypes.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Pawn.h"
//#include "GameplayDebuggerPlayerManager.h"
//#include "Engine/Engine.h"

GameplayDebuggerExtension_Player::GameplayDebuggerExtension_Player()
{
  const FGameplayDebuggerInputHandlerConfig KeyConfig(TEXT("PrintPlayer"), EKeys::NumLock.GetFName());
  bHasInputBinding = BindKeyPress(KeyConfig, this, &GameplayDebuggerExtension_Player::PrintPlayerLocation);
}

FString GameplayDebuggerExtension_Player::GetDescription() const
{
  if (!bIsCachedDescriptionValid)
  {
    CachedDescription = !bHasInputBinding ? FString() :
      FString::Printf(TEXT("{%s}%s:{%s}Player"),
        *FGameplayDebuggerCanvasStrings::ColorNameInput,
        *GetInputHandlerDescription(0),
        *FGameplayDebuggerCanvasStrings::ColorNameEnabled);

    bIsCachedDescriptionValid = true;
  }

  return CachedDescription;
}

TSharedRef<FGameplayDebuggerExtension> GameplayDebuggerExtension_Player::MakeInstance()
{
  return MakeShareable(new GameplayDebuggerExtension_Player());
}

void GameplayDebuggerExtension_Player::PrintPlayerLocation()
{
  UE_LOG(LogTemp, Warning, TEXT("Player's Location: %s"), *GetPlayerController()->GetPawn()->GetActorLocation().ToString());
}

#endif

注册扩展

就像我们对 游戏调试器类别 所做的那样,我们还需要注册 扩展

然而,在我们这样做之前,如果我们尝试编译,我们将得到一个错误。实际上,由于我们处理 扩展 的输入,扩展 所在的模块需要向 "InputCore" 的 公共依赖。在你的 .build.cs 文件中添加以下行:

PrivateDependencyModuleNames.Add("InputCore");

特别是,对于我们的定位模块,你应该在 GameplayDebugger_Locator.build.cs 文件中这样插入这个依赖项:

        if (Target.bBuildDeveloperTools || (Target.Configuration != UnrealTargetConfiguration.Shipping && Target.Configuration != UnrealTargetConfiguration.Test)) {
            PrivateDependencyModuleNames.Add("GameplayDebugger");
 PrivateDependencyModuleNames.Add("InputCore");
            Definitions.Add("WITH_GAMEPLAY_DEBUGGER=1");
        } else {
            Definitions.Add("WITH_GAMEPLAY_DEBUGGER=0");
        }

在此修改后编译,你不应该得到任何错误。

现在,是时候注册扩展并通知 游戏调试器 这个变化了。为此,我们需要使用特定的函数。因此,在我们的 StartupModule() 函数(在 GameplayDebugger_Locatot.cpp 文件中),我们需要添加以下加粗的代码行,以便相应地注册和通知 游戏调试器(注意,我们需要为 扩展类别 都这样做,因为它们是两个不同的函数):

 void FGameplayDebugger_LocatorModule::StartupModule()
{

#if WITH_GAMEPLAY_DEBUGGER

  UE_LOG(LogTemp, Warning, TEXT("Locator Module Loaded"));

  if (IGameplayDebugger::IsAvailable())
  {
    IGameplayDebugger& GameplayDebugger = IGameplayDebugger::Get();

 GameplayDebugger.RegisterExtension("Player", IGameplayDebugger::FOnGetExtension::CreateStatic(&GameplayDebuggerExtension_Player::MakeInstance));

 GameplayDebugger.NotifyExtensionsChanged();

    GameplayDebugger.RegisterCategory("Locator", IGameplayDebugger::FOnGetCategory::CreateStatic(&FGameplayDebuggerCategory_Locator::MakeInstance), EGameplayDebuggerCategoryState::EnabledInGameAndSimulate);

    GameplayDebugger.NotifyCategoriesChanged();

    UE_LOG(LogTemp, Warning, TEXT("GameplayDebugger Registered"));
  }

#endif
}

在模块关闭时注销 扩展 时,同样的方法也适用。以下是我们在 ShutdownModule() 函数中需要添加的代码:

void FGameplayDebugger_LocatorModule::ShutdownModule()
{

#if WITH_GAMEPLAY_DEBUGGER

  if (IGameplayDebugger::IsAvailable())
  {
    IGameplayDebugger& GameplayDebugger = IGameplayDebugger::Get();

 GameplayDebugger.UnregisterExtension("Player");

 GameplayDebugger.NotifyExtensionsChanged();

    GameplayDebugger.UnregisterCategory("Locator");

    GameplayDebugger.NotifyCategoriesChanged();

  }
#endif
}

编译代码,你的插件就准备好了。你可能需要重新启动编辑器才能使效果生效。

如果你仍然在使用游戏调试器可用的情况下加载插件时遇到麻烦,请从虚幻引擎的顶部菜单导航到 窗口 -> 开发者工具 | 模块。从这里,搜索我们的定位模块,然后按如下截图所示按下重新加载:

你可能每次加载编辑器时都需要这样做,以便使用你的类别和/或扩展。

如果你进入 游戏调试器设置,你将找到我们的 扩展 列表(如果你愿意,你还可以更改按键绑定):

这就是它在游戏中的样子:

这是输出截图。其他(模糊显示)的信息在这里并不重要

这里是一个特写:

如果你按下 P,那么扩展将在 输出日志 中产生以下结果:

关于游戏玩法调试器扩展的更多信息,你应该查看GameplayDebuggerExtension.h中包含的类(创建扩展游戏玩法调试器的基类)以及GameplayDebuggerExtension_Spectator.h(一个扩展的实现,其中包含输入绑定缓存描述的示例)。

这标志着我们扩展游戏玩法调试器的冒险之旅结束。

摘要

在本章中,我们探讨如何利用游戏玩法调试器来测试我们的 AI 系统。特别是,我们研究了游戏玩法调试器的默认类别和扩展,它们是如何工作的,以及它们显示哪种类型的信息。

然后,我们看到了如何通过创建一个新的类别和一个新的扩展插件扩展游戏玩法调试器。结果,我们解锁了我们自己系统调试的巨大潜力。

在下一章中,我们将进一步探讨游戏中的 AI,并看看还有哪些可能性。

第十四章:超越

在本章的最后,我们将讨论本书中我们已经涵盖的内容,以及遗漏的内容。我们将有一些关于人工智能和心理学的一般性思考,每个游戏人工智能开发者都应该了解。因此,本章将以一些关于如何探索本书中提出(以及其他)的概念的建议以及一些关于人工智能的思考作为结尾。

无法想象一个未来,其中人工智能AI)不会无处不在。它将以我们甚至未曾想象的方式融入一切。因此,为了使人工智能的设计真正能够与人类建立联系,心理学需要始终处于这些体验设计的核心。年复一年,我们可以观察到世界对技术的依赖性日益增强,在某些情况下,技术填补了人类互动的空白。这使我们能够与高度个性化和定制化的体验互动,这些体验在众多个体中使我们真正感到独特。

通常,人工智能程序员会忘记他们为谁实施系统:玩家。因此,心理学扮演着重要的角色:玩家对人工智能系统的感知比系统本身的性能更重要。本章探讨了玩家心理学与游戏中的 AI 之间的这种联系。

在本章中,我们将学习以下主题:

  • 为什么心理学对人工智能是一个重要的考虑因素?

  • 本书未涉及的内容

  • 一些有用的资源

  • 人工智能与哲学

为什么心理学对人工智能是一个重要的考虑因素?

到现在为止,你可能已经开始怀疑为什么心理学是人工智能发展不可或缺的一部分。心理学之所以重要,是因为它教会了我们人类思考和行为的根本推理。因此,当涉及到为用户个性化、定制和自动化体验时,我们找到将其纳入我们设计中的方法是有意义的。在考虑 AI 在互动体验中的自主角色时,它必须在某种程度上与我们作为玩家如何与虚拟世界互动和参与的方式相一致;我们如何与物体、非角色扮演者(NPCs)和其他玩家互动。思考这一点的其中一种方式是通过因果关系。例如,我们执行特定的动作,如从不同的角色那里偷窃或表现不佳,这反过来又会影响其他 NPC 如何回应你。一个很好的例子是在游戏《Overlord》中,你越是为了恐吓村庄而做出过激行为,他们就越不愿意帮助你,甚至与你互动。虽然这可能很有趣,但也可能相当令人沮丧,尤其是在你需要完成某些目标而被迫以困难的方式去做时,因为你现在让所有人都讨厌你!因此,记住,为了在游戏中创建 AI,你需要定义一个衡量玩家进步的指标,并在开始游戏测试时相应地调整它。

心理学与 AI 结合使用的方法有很多。例如,许多网站使用推荐系统,这些系统要么关注你购买或查看的内容,并根据这些内容提出建议。这可以在你浏览目录、将商品添加到购物车或甚至通过电子邮件以“个性化促销”的方式诱使你回来时进行。

游戏中的心理学与 AI

在游戏中,AI 被广泛使用。与 AI 不同,机器学习有可能在游戏中实现,但鉴于大多数游戏体验的短暂性,通常没有足够的数据供机器“学习”任何东西,更不用说将其应用于游戏玩法。因此,这并不是一个经济上或资源上可行的选择。在这种情况下,“能做并不意味着应该做”这句话将是处理这种情况最明智的方法。

更具体地说,心理学在 AI 中的应用可以针对许多其他领域,例如利用角色(和玩家)的情感。在新兴技术和使用方式的时代,我们发现一些游戏试图让玩家感觉游戏“真实”。开发者试图找到方法跨越现实和游戏的界限,并真正鼓励玩家做出道德上有疑问的选择(例如《特种部队:线》、《暴雨》),这些选择会触动我们的心弦,甚至让我们反思自己的行为。

一些游戏旨在在玩家玩游戏时对你进行“心理”分析。例如,《寂静岭:破碎记忆》使用心理分析来了解你,然后在游戏过程中使用这些信息来改变游戏,正如他们所说的,创造你自己的个人噩梦。

这非常直观地通过游戏中的一个部分来实现,在这个部分中,一位心理学家——迈克尔·考夫曼博士,或者简称 K 博士——会问你一系列不同的问题,以开始你的个人档案建立过程。但这只是档案建立的基础——随着游戏的进行,档案本身会根据你的互动方式、你频繁或偶尔做的事情(例如检查你的地图)进行修改。

在游戏中,当涉及到玩家互动时,情感是玩家最有用的指示之一。一个角色的情感可以传达愤怒、快乐、悲伤和挫败感,许多游戏通过复杂和详细的叙事选择、角色声音、表情和整体存在感来传达这一点。在许多方面,情感可以极大地影响游戏氛围,更不用说它们对玩家在互动中如何互动、响应或做出选择的影响了。然而,情感的作用远不止传达氛围那么简单。它们极大地影响了我们的决策方式。例如,如果一个角色在游戏过程中没有经历过压力事件或挑战,那么他们不太可能进行思考;而如果一个角色刚刚险些逃脱一个 Boss,现在必须快速做出决定(同时计时器正在倒计时)关于是否射击目标,那么背后的思考过程可能会有很大的不同。我所经历的最佳例子之一是在玩《特种部队:战线》时(前方有剧透)。在花了大部分时间杀敌之后,你终于到达了一个阶段,你可以将他们逼入死角,但他们随后开始“逃跑”。通过直升机切换到空中视角,你可以看到他们似乎正在汇聚到一个没有退路的角落。完美!对吗?所以,自然地,沉浸在那一刻,你不断地按按钮,试图将愤怒倾泻在他们身上,试图摧毁最后一个敌人。然而,那群发光的人并不是在逃跑。他们被困住了——他们没有反击。不用说,在将地狱倾泻在敌人身上的那一刻,你无意中失败了任务——拯救无辜者。在这个时候,你精神上已经筋疲力尽,从现在开始,在每一个对话选项中,你开始更加仔细地思考你的选择以及它们对他人产生的影响——尤其是如果你之前没有这么做的话。

通过心理学构建更好的 AI

要让一个非人类(AI)的行为看起来像人类,你需要记住人类并不总是理性的——我们常常是不可预测的。在做出选择(甚至在游戏中)关于接下来做什么之前,人类会考虑许多不同的事情。例如,他们可能会在决定攻击玩家之前考虑社会信息,比如玩家的排名(地位、等级等)。这很可能会影响叙事。此外,玩家在决策时可能会使用心理捷径,这取决于他们自己的心理模型,可能会导致各种不可预测的行为。这还可以受到游戏环境和先前经验的影响。因此,你如何教会计算机模仿,更不用说以可信的方式做出反应呢?

开发者需要设计 AI,在这个过程中,他们也需要考虑其背后的心理学。例如,AI 是否应该以玩家可能期望的方式(类似于正常人类)回应玩家?也许开发者创造了一个存在惯例的幻想世界,因此 AI 必须相应地做出反应(这可能超出了现实世界的惯例)。在所有情况下,都始终存在与玩家心理学相关的问题,开发者需要在设计 AI 行为时牢记这些。因此,作为一名开发者,你需要考虑 AI 如何以令人信服的方式与游戏环境互动。你需要让 AI 意识到其环境,遵守游戏规则,并相应地做出反应/行为。让玩家通过走进碰撞盒来触发 AI 的即时反应是一种破坏沉浸感的方法。AI 需要考虑其当前情况,并具有情境意识。

另一个需要考虑的因素是玩家可能形成的与非玩家角色NPCs)之间的关系。在玩家能够影响动态和分支叙事的游戏中,目标是让玩家感受到与游戏以及其中角色的联系。最终,你希望玩家能够“感同身受”其他角色,那么如何做到这一点呢?那就是通过开发出能够与人类心理相连接的令人信服的 AI。这可以通过一种方式来实现,即开始感觉像是一个交互式的图灵测试。在这个阶段,你可以看到至少有几种方式你可以利用心理学来影响 AI 的设计。

除了分支叙事之外,另一个重要的方面是能够将情感融入人工智能与玩家的互动中。例如,如果一个玩家以负面方式与其他 NPC 互动,这将会影响人工智能的反应方式。这不仅仅是在叙事方面,还包括对玩家的态度。例如,一个 NPC 在开始时可能愿意帮助你——也许甚至在你交易时提供折扣。然而,根据你与其他 NPC 的关系(例如,负面态度),你的行为不可避免地会影响那些你没有以相同方式对待的 NPC 的态度(例如,那些之前对你有积极态度的人)。

这种讽刺之处在于,为了让人工智能看起来可信,就需要让它不完美。人类并不完美——在许多情况下我们离完美还很远——因此人工智能也应该如此。例如,如果一个人工智能正在追逐玩家——甚至射击他们——人工智能每次都能击中玩家,就像人类玩家一样,这是没有意义的;人工智能应该会错过。应该是一种人工智能几乎赢/输的情况。这样,即使在一定程度上给玩家带来了一种虚假的安全感,它也会让他们觉得自己在取得成就。这远远超过了一个“上帝模式”的场景,玩家可以轻易地摧毁一切,或者相反,因为人工智能过于强大,玩家难以前进。当然,这种平衡可以受到玩家设置的难度设置的影响。通过这样做,你能够为玩家的沉浸感做出贡献,也就是说,使游戏环境看起来可信,玩家在与另一个“人”互动,而不是仅仅与游戏中的一个脚本角色互动。

与“可信度”同样重要的是人工智能的反应时间。正如你不想让他们比玩家拥有的能力更强大一样,他们也需要以与玩家相同的速度处理事件进行反应。例如,玩家(即人类)的平均反应时间为 0.2 秒(视觉)和 0.15 秒(听觉)(www.humanbenchmark.com)。这意味着如果我们站在一个房间里,如果另一个人打开门,我们需要 0.2/0.15 秒来对此做出反应,无论是转头去看谁在门口,还是阻止门打开,甚至躲藏起来。因此,人工智能在反应时间上需要表现出同样的行为,尽管有所不便,以避免在人类玩家之上获得优势。然而,这还涉及到许多其他事情,比如理解一个事件(例如,爆炸是炸弹还是汽车回火?)然后做出反应(例如,寻找敌人,忽略,调查)。记住,对于这些类型的事件,必要的反应时间和反应都需要实现。

通过使用心理学来驱动 AI 的设计,我们有一个框架,帮助我们创建可信的叙事、角色和环境,以便我们可以创建更沉浸式的游戏环境。此外,心理学的考虑并不特定于 AI 的设计——我们可以在游戏事件方面在此基础上进行构建。例如,在环境中移动的 NPC 可能有自己的故事,自己的“心理学”,这驱使它们的移动,甚至与玩家的互动。例如,NPC 角色可能会相遇,发生某种争执,从而影响玩家未来与他们的互动。如果游戏中有玩家可以与之互动的角色,并且确实(以某种特定方式)或根本不与之互动,这可能会影响游戏体验中后期 AI 与玩家的互动。随机化是另一个可以帮助提高游戏环境中 AI 真实性的方面。当然,在某种程度上,NPC 在行走时可能会遵循相同的路径,尤其是在有守卫保护区域的情况下。然而,偶尔允许 NPC 偏离其路径,比如绕过喷泉而不是在其后面,都可以有助于环境的真实性。

最后,在我们结束这一章之前,测试你的 AI 对于其成功至关重要。就像人类行为不可预测一样,你的 AI 的行为也可能不可预测。这在路径寻找方面尤其普遍。例如,当一个玩家应该遵循一条路径时,如果另一个 NPC 或甚至玩家穿越它,可能会导致 AI 以奇怪的方式行事。结果,这可能会破坏玩家的沉浸感,或者更糟,由于故障而破坏游戏机制和/或平衡。

在我们结束这一章的时候,我可以向你推荐的一条建议是,即使你不需要获得心理学学位来创建令人信服的 AI,但如果你真正想要创建与玩家在更深层次上产生联系的 AI,这会很有帮助。

机器学习

仅仅人工智能本身并不足以“真正”与玩家建立联系。需要更复杂和细致的方法,以便技术以更亲密的方式与我们同步。因此,机器学习为我们提供了一整套知识,可以用来了解用户喜欢什么,不喜欢什么,跟踪他们的交互和行为,并相应地调整系统——这包括指导 AI 下一步该做什么。简单来说,机器学习是人工智能的一个子领域。这一点非常重要,因为在许多情况下,人们经常混淆不仅人工智能和机器学习的定义,还有它们所取得的成果。机器学习允许系统从数据中自主学习,而不是开发者提供数据——或者将其硬编码到交互中。

在考虑人工智能和心理学之前,最后一点需要注意的是不要高估任何一方的能力。它们各自都有局限性,我们对它们各自领域的了解有限(尽管在增长),以及它们如何共同使用。因此,当你考虑将它们作为你自己的项目的一部分时,务必进行调研。

游戏中人工智能和机器学习的应用

在某些情况下,你可能会并且可以使用人工智能在游戏中,但在这类情况下,它与你在使用机器学习时的实例会有所不同。然而,了解这两者之间的区别非常重要,正如我们已经讨论过的,而且还要了解它们在游戏环境中的应用和实施方面的差异。

游戏中人工智能的应用

如果需要游戏中具有自主角色的功能,游戏设计师可能会考虑在游戏中使用人工智能。例如,如果你想有敌人会在玩家射击他们时四处奔跑追逐,或者当玩家过于接近时检测到玩家。

游戏中机器学习的应用

另一方面,在游戏中使用机器学习需要稍微不同的方法和考虑因素。就像我们的 AI 例子一样,如果你想使游戏“学习”玩家的习惯,你可以在游戏环境中实现机器学习。

适应性行为

人工智能在游戏玩法中的一个实际应用是能够根据玩家的输入调整游戏环境(以及其中包含的一切)。例如,如果玩家在某个关卡的部分不断失败,那么玩家会在更靠近该部分的地方重生,或者敌人变得更容易攻击。这种调整难度的过程被称为动态难度调整,它非常棒,因为它允许玩家在游戏中的一些部分比其他部分更具挑战性的情况下前进。游戏《 Crash Bandicoot》就是一个很好的例子,它实现了这一机制,并允许你享受既具挑战性又可玩的游戏体验。

本书尚未涉及的内容

嗯,几乎一切都可以!

尽管这本书中有许多页面,但我们只是刚刚触及游戏 AI 这个美妙主题的表面。我在这本书里说过多少次“遗憾的是,这个[插入主题]超出了本书的范围”?太多以至于难以记住。这只是为了虚幻引擎内置的 AI 框架(例如,我们还没有涵盖如何正确扩展 EQS 的生成器和测试,或者如何创建自定义 AI 感知)。然而,我坚信这本书为你提供了一些坚实的基础,可以在此基础上迭代并继续学习虚幻引擎 AI 框架。希望你能将这本书作为你在虚幻引擎工作中的参考。

尽管我希望这本书能帮助你奠定基础,但本节的目的在于指出,这条路是由成千上万的石头铺成的。通过随机指向主要的部分,我希望激发你继续在这条路上前行。我们将在接下来的章节中更详细地讨论这些“石头”。

动作和导航(低级)

在第三章导航中,我们探讨了虚幻引擎内置的导航系统。然而,我们没有讨论动作,这在我们在第一章在 AI 世界中迈出第一步中提出的 AI 架构中有所体现。

动作处理角色的加速度和速度,包括避开障碍物和其他代理。当然,我们在第七章人群中探讨了避开,而在虚幻引擎中,加速度和低级运动属于动作组件。然而,关于引导行为的部分专门处理控制代理的加速度以创建更逼真的行为,这部分我们尚未探讨。

赛车游戏

赛车游戏绝对是一个独特的案例。实际上,我们需要一个能够执行连续路径查找(这在其一般形式中仍然是一个未解决的问题)并能与不同的游戏玩法机制竞争的 AI。许多其他 AI 算法在那里发挥作用,例如平衡游戏(例如,在所有汽车之间创建一个“虚拟弹簧”)。

代理意识

代理意识涉及赋予 AI 代理感知能力——特别是视觉,这是最常见和最广泛使用的,但还包括听觉和嗅觉。此外,我们可以开始探索如何将此数据用于高级结构,以便代理能够相应地行动。

环境查询系统

环境查询系统ESQ)可以从代理周围的环境中收集信息,使代理能够据此做出决策。本书专门用一章来介绍这个系统。实际上,它位于代理意识决策制定之间,并且是已经内置到虚幻引擎中的宝贵资源。

决策和规划

一旦智能体能够感知周围的世界并进入其中,它就需要通过做出有后果的决定来采取行动。某些决策过程可能会变得非常复杂,以至于智能体需要制定一个适当的计划才能成功实现目标。

我们刚刚探讨了行为树,但还有许多值得探索的决策算法和系统,从有限状态机(FSMs)到效用函数,再到多智能体系统和规划算法。

离散游戏/棋盘游戏

有时,智能体周围的世界不是连续的,而是离散的,并且可能是回合制的。因此,智能体不能总是使用我们在本书中探讨的算法。博弈论和离散游戏的数学原理对于许多游戏应用都很重要,从回合制游戏到战斗系统中资源的战略分配。

学习技巧

即使当你思考游戏中的 AI 时,智能体可能是首先想到的,但一个游戏可能包含许多其他 AI 算法。尽管由于学习技巧并不总是导致一致的结果,并且大多数情况下它们不是游戏所必需的,但这个主题开始对游戏产业产生吸引力。

程序性内容生成(PCG)

程序性内容生成PCG)是另一种与智能体无关的人工智能算法的例子,而是游戏中的整个世界为玩家生成内容。这些技术不仅被关卡设计师用来创建世界,艺术家用来定制网格或纹理,而且在玩家玩游戏时,在运行时创建一个无限的世界,通常是在街机游戏中。

搜索与优化

搜索与优化是任何 AI 开发者的基本工具,因为这些是在人工智能中广泛使用的核心技术,但在开发游戏 AI 系统时可能会派上用场。尽管这些技术不在玩家面前使用,但它们在游戏引擎的底层被使用。

多智能体系统

通常,视频游戏中的智能体并不是独自行动的,它们需要协作以实现共同目标。多智能体系统在视频游戏中并不常见,因为它们有时难以实现,但如果它们能够很好地结合,就能实现非常逼真的行为。因此,大多数情况下,协作是虚假的,但这个问题可以通过多智能体系统来解决,尤其是在游戏设计需要智能体之间更高级的交互时。此外,这些系统适用于在拥有许多智能体的在线游戏中实现逼真的行为,其中计算能力可以在多台机器上分布。

人工智能与心理学

本章解释了人工智能在游戏开发工作流程中的更广泛作用,以及它应该如何与游戏设计相结合,以充分利用不同的系统。本节的目标是向您介绍不同常见和不那么常见的情况,在这些情况下,一个 AI 算法比另一个更适合。此外,本节还将向您展示 AI 与心理学之间的关系,以及前者如何影响后者。实际上,AI 系统的最终用户是玩家,他/她如何感到舒适或相信 AI 系统是一个关键因素。

超越经典 AI

目前在 AI 的世界中正在进行许多激动人心的新想法和创新,你应该始终通过内心的好奇心来继续你的美妙旅程。了解新技术和算法是始终保持最新状态的关键。

还有更多,更多,更多!

由于你可以探索的内容还有很多,所以这个列表绝不是详尽的!

一些有用的资源

如果你渴望学习更多,我完全可以理解。尽管社区非常活跃、友好,但找到关于这个主题的好材料很难。

当然,首先应该查看的是官方文档 (docs.unrealengine.com/en-us/),它需要与论坛问答社区相结合。

其次,参加活动是与人们建立联系和分享知识的好方法。你知道虚幻引擎在全球各地都有官方的聚会吗?去看看吧:www.unrealengine.com/en-US/user-groups

其他活动包括虚幻节游戏开发者大会(GDC)科隆游戏展(Gamescom)(以及其他许多活动)。

如果你正在寻找更传统的资源,例如书籍和博客,我找到了一些关于虚幻引擎(特别是与 AI 相关的)的资源,我认为它们特别有用。这些是个人博客,但它们包含非常实用的信息。我希望我能列出所有这些资源,也许我们应该做一个列表,因为周围有很多在做着令人惊叹的事情的人。这些只是其中的一小部分:

  • Tom Looman 的博客 (www.tomlooman.com):你可以找到各种内容,尤其是关于 C++的。这里有一篇关于实用 AI的有趣文章:www.tomlooman.com/journey-into-utility-ai-ue4/

  • Orfeas Eleftheriou 的博客 (orfeasel.com):一个关于非常有趣主题的丰富博客。特别有趣的是关于如何扩展 AI 感知系统的文章,你可以在这里找到:orfeasel.com/creating-custom-ai-senses/。老实说,我本来打算为这本书添加一个关于扩展 AI 感知系统的章节,但不幸的是,我没有时间。我希望这篇文章能让你走上正确的道路。

  • Vikram Saran 的博客 (www.vikram.codes):目前这是一个小博客,但你可以找到一些关于 导航系统 的额外信息。

人工智能与哲学

最后,我想以一些关于 AI 的思考来结束这本书,这些思考可能会让你觉得有趣。

如果我们在考虑人工智能的哲学方面,需要提出的关键问题是:心灵是如何工作的?机器能否以人类的方式表现出智能?机器能具有意识吗?

这些简单的问题在过去的一个世纪中引发了大量的争议(并且现在仍在继续)。本节的目的不是对这些问题进行彻底的讨论,而是提供关于这些问题的有趣对话的非常一般性的想法,并激发你对阅读更多相关文献的兴趣。

哥德尔的不完备性定理表明,对于任何足够强大以进行算术的形式公理化系统 F,都可以构造出一个具有以下性质的句子,G (F):

  • G (F) 是 F 的一个短语,但无法在 F 内部证明

  • 如果 F 是一致的,那么 G (F) 是真的

一些哲学家认为哥德尔定理表明机器不如人类,因为它们是受定理限制的形式系统,而人类没有这样的限制。或者,是这样吗?

似乎无法证明人类不受哥德尔定理的影响,因为任何严格的证明都需要对人类才能进行形式化,而这种形式化会被拒绝。

已知人类在日常推理中是不一致的,这可能会使我们无法证明我们的优越性。

弱人工智能假设 意味着机器可以表现得像是有智能的。在 1950 年,艾伦·图灵建议,我们不应该问机器是否能思考,而应该问自己机器是否能通过行为智能测试,这已经成为图灵测试。测试要求程序与审问者进行五分钟的对话(通过在线输入的消息)。因此,审问者必须猜测对话是与程序还是与人进行的;如果程序有 30% 的时间欺骗了审问者,则程序通过测试。图灵推测,到 2000 年,计算机可以被编程得足够好,以通过这个测试。

今天,像 Natachata 这样的互联网聊天机器人反复欺骗了他们的通讯者,Cyberlover 聊天机器人甚至能诱使人们透露足够多的个人信息,以至于他们的身份可能被盗。

另一方面,强人工智能假设声称机器可以真正思考,而不仅仅是模拟思考。

机器能否意识到它们的心理状态和行为?它们真的能感受到情感吗?

图灵本人反对这些问题,认为在日常生活中,我们从未有直接证据证明其他人类的内部心理状态。围绕我们的其他人是在思考,还是在模拟?

我们能否想象一个未来,那时与机器进行现实对话成为常态,不再区分真实和模拟思维的语言差异?图灵认为,一旦机器达到一定程度的复杂性,这些问题最终会自行消失,从而消除强人工智能和弱人工智能之间的差异。

另一个重要的问题是意识,它通常被分为理解和自我意识两个方面。

图灵承认良知问题很难,但否认它对人工智能的重要性。人类可以创造思考机器,但意识是否产生,这远远超出了我们当前的知识。

一个重要的争论涉及心灵和身体的物理构成以及它们之间的分离。

如果心灵和身体真的分离,心灵如何控制身体?

一种称为物理主义的理论断言,心灵并非与身体分离,但尚无需解释我们心中的生化过程如何产生心理状态(如恐惧或愤怒)和情感。

功能主义理论认为,心理状态是输入和输出之间的中间因果条件。在适当的条件下,人工智能可能具有与人类相同的心理状态。基本假设是存在一个抽象层次,在此层次以下,具体的实现并不重要。

生物自然主义对功能主义提出了强烈的挑战,根据这种观点,心理状态是神经元低级物理过程中出现的高级特征。

中国房间是一个思想实验,证明了这一愿景。它由一个只懂英语的人类组成,配备一本英语语法书和几堆纸张。系统位于一个带有小开口的房间里。

不可解的符号通过开口出现在纸上。人类在规则书和指示中检查这些符号,并遵循指示。这些指示可能包括在新的纸张上写符号,在堆叠中寻找符号,重新排列堆叠等。最后,指示将导致一个或多个符号被转录到返回外界的纸张上。

从外面看,我们看到的是一个系统,它接收中文句子作为输入,并以中文生成合理的答案。

中国房间系统会讲中文吗?语言解释发生在什么水平?这真的会发生吗?

如需更多信息,请访问 en.wikipedia.org/wiki/Chinese_room

我将以一些简短的伦理考虑结束。好吧,我们可能能够创造智能...但我们应该这样做吗?我们是否有道德责任?

许多工程师和科学家都关注过他们所处理的技术中的伦理问题。想想核裂变,或者甚至汽车,每年都会导致大量死亡。

人工智能引发了许多问题,从人们可能失去工作到对失去我们在地球上的特权地位的恐惧,从将机器人作为死亡工具的军事用途到关于人类与人工智能之间关系的道德问题。但可能最普遍的关于人工智能的伦理反对意见是,人工智能的出现可能意味着人类种族的终结。

好莱坞工作室制作了无数关于赛博格或与人类作战的智能网络的科幻电影。最近,辩论已经转向不同的方面,例如自动驾驶汽车可能导致乘客死亡的事故,或者可能错误地发射反击的导弹防御系统,导致数十亿人死亡。

减缓这些风险的正确方法是将这些控制系统纳入这些系统,以便单个错误不会扩散到未计划的结果。

但要施加给人工智能的规则(从机器人阿西莫夫定律开始)可能是伦理影响的核心。我们必须非常小心我们提出的要求!

由于自然选择,人类有时会以侵略的方式使用他们的智慧。我们构建的机器不能本质上是侵略性的,除非它们是鼓励侵略行为的机制最终产品。

最重要的风险是人工智能的演变导致不可预见的行为。从一开始就不伤害人类的愿望可以设计出来,但工程师应该认识到他们的设计可能不完美,系统会随着时间的推移学习和演变。因此,真正的挑战是在制衡体系中实施人工智能系统演变的计划,并赋予系统在面临这些变化时保持友好的效用函数。

我们需要希望人工智能能够容忍与我们一起分享这个星球!

再见

我希望您喜欢与我一起的这次旅程,并希望我们的道路再次相交。

直到那时,我祝愿您在进入人工智能游戏开发的旅程中一切顺利。

如果你想要留言,你随时可以联系我。你可以在我的网站上找到我的详细信息:www.francescosapio.com.

posted @ 2025-10-27 09:10  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报