Unity-游戏开发实用指南第四版-全-

Unity 游戏开发实用指南第四版(全)

原文:zh.annas-archive.org/md5/3f4ee7a03a6beb2ba909f3562cff5f9f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我清晰地记得我告诉父母我要学习游戏开发时的那种恐惧感。当时,在我的地区,这种抱负被视为幼稚且缺乏前途。然而,我的坚韧和决心使我不断追求这个梦想。如今,游戏行业已经呈指数级增长,甚至超过了电影行业。

当然,追随我的梦想比我想象的要困难得多。与我有着相同梦想的任何人迟早都会意识到在这个领域所需的知识的复杂性和深度。遗憾的是,这个陡峭的学习曲线导致许多人放弃,但我坚信,有了适当的指导和工具,你可以使你的职业道路更加容易。对我来说,掌握 Unity 是简化我的学习过程的关键。而这本书就是这一经验的结晶,旨在使你进入游戏开发世界的旅程更加顺畅。

欢迎阅读关于 Unity 2023 的这本书。这本书将向你介绍最新的 Unity 功能,以便你可以以最简单的方式创建你的第一个视频游戏。Unity 提供了一系列用户友好且功能强大的工具,用于解决常见的游戏开发挑战,如渲染、动画、物理、音效和特效。我们将利用所有这些功能来创建一个简单但完整的游戏,深入探讨 Unity 的所有复杂性。

到这本书结束时,你将拥有使用 Unity 所需的所有技能,这将使你能够深入探索你感兴趣的各种游戏开发领域,无论是为了建立职业还是仅仅为了以游戏作为爱好带来的乐趣。Unity 是一个适用于专业和业余项目的多用途工具,并且每天都有越来越多的人在使用它。

Unity 的实用性不仅限于游戏创作;它是一个强大的平台,适用于各种交互式应用程序,从简单的移动应用到复杂的教育和培训程序,通常被称为严肃游戏。这些应用程序利用了增强现实和虚拟现实等尖端技术。因此,虽然我们在这里的主要重点是游戏开发,但你正在开始一段学习之旅,它提供了广泛的潜在专业方向。

这本书面向的对象

这本书经过精心设计,旨在满足来自不同背景的人的需求。如果你对面向对象编程OOP)有基本的了解,但对游戏开发或 Unity 从未使用过,你会发现这本书是游戏开发和 Unity 从基础到高级概念的良好入门。即使是经验丰富的 Unity 开发者,在本书的许多章节中也能发现宝贵的见解。

另一方面,如果你没有任何编程经验开始学习,这本书仍然非常易于入门。许多章节都设计为提供信息,而不需要先前的编码知识。这些部分将为你学习如何在 Unity 中编码打下坚实的基础,使学习曲线比其他方式更加平缓。一旦你掌握了编码的基础,本书的脚本重点章节将变得越来越有益。此外,随着 Unity 中视觉脚本的引入,那些更喜欢基于节点的脚本的人有了吸引人的替代选择。

本书涵盖内容

第一章踏上 Unity 之旅,教你如何在电脑上安装和设置 Unity,以及如何创建你的第一个项目。

第二章制作场景和游戏元素,教你场景和游戏对象的概念,以及 Unity 描述游戏世界组成的 Unity 方式。

第三章从蓝图到现实:使用地形和 ProBuilder 构建,我们将在这里创建我们的第一个关卡布局,并使用 Unity 的地形和 ProBuilder 功能进行原型设计。

第四章无缝集成:导入和集成资源,我们将在这里创建我们的第一个关卡布局,并使用 Unity 的地形和 ProBuilder 功能进行原型设计。

第五章释放 C#和视觉脚本的力量,是本书的第一章编程内容。我们将学习如何在 Unity 中使用 C#创建第一个脚本,然后我们将探索如何使用新的基于节点的编码语言——视觉脚本来完成同样的任务。

第六章动态运动:实现移动和生成,教你如何编程对象的移动以及如何生成它们。本章介绍了新的 Unity 输入系统。从这一点开始,假设具备一般的编程知识。

第七章碰撞与健康:准确检测碰撞,教你如何配置对象的物理设置以检测两个对象何时发生碰撞并对此做出反应,例如创建一个健康系统。

第八章胜利或失败:胜负条件,涵盖了如何检测游戏何时应该结束,无论是玩家胜利还是失败。

第九章开始你的 AI 之旅:为你的游戏构建智能敌人,涵盖了使用几个 Unity 功能创建基本 AI,以在我们的游戏中创建具有挑战性的敌人。

第十章材料炼金术:使用 URP 和 Shader Graph 实现震撼视觉效果,展示了如何使用最新的 Unity 渲染系统(通用渲染管线,或 URP)以及如何使用 Shader Graph 功能创建效果。

第十一章引人入胜的视觉效果:利用粒子系统和视觉效果图控制台,教您如何使用 Unity 的两个主要工具——粒子系统和视觉效果图控制台——来创建水、火等视觉效果,以及如何编写根据游戏中的情况控制它们的脚本。

第十二章启迪世界:使用通用渲染管道照亮场景,探讨了照明,这是一个足够大的概念,可以拥有自己的章节。在这里,我们将深化我们对通用渲染管道的了解,特别是其照明功能。

第十三章沉浸式现实主义:使用后处理实现全屏效果,教您如何使用通用渲染管道的后处理功能在场景图形上添加一层效果,以获得当今大多数游戏都有的电影效果。

第十四章和谐的音景:集成音频和音乐,涵盖了一个大多数初学者开发者都低估了的话题;在这里,我们将学习如何正确地将声音和音乐添加到我们的游戏中,并考虑其对性能的影响。这也包括如何编写声音脚本。

第十五章界面魅力:设计用户友好的 UI,探讨了用户界面UI)。在所有将信息传达给用户的方式中,UI 是最直接的一种。我们将学习如何使用 Unity UI 系统以文本、图像和生命条的形式显示信息,以及如何编写 UI 脚本。

第十六章下一代 UI:使用 UI Toolkit 创建动态界面,探讨了 UI Toolkit,它是我们在上一章中学到的 Canvas UI 系统的继任者。我们将探索它,以便提前准备并适应 Unity 未来对基于 HTML 的工具包的使用。

第十七章动画现实:使用 Animator、Cinemachine 和 Timeline 创建动画CinemachineTimeline,将我们带入了迄今为止所创建的静态场景之外。在本章中,我们将开始移动我们的角色并使用最新的 Unity 功能来创建场景,以及如何编写它们的脚本。

第十八章性能魔法:使用 Profiler 工具优化您的游戏,讨论了使我们的游戏表现良好并非易事,但确实是发布游戏所必需的。在这里,我们将学习如何分析游戏性能并解决最常见的性能问题。

第十九章从原型到可执行文件:生成和调试您的游戏,教您如何将 Unity 项目转换为可执行格式,以便将其分发给其他人并在没有 Unity 安装的情况下运行。

第二十章AR/VR,教您如何使用 Unity 的 AR Foundation 包创建 AR 应用程序,这是使用 Unity 创建 AR 应用程序的最新方法之一。

第二十一章大规模世界:DOTS 简介,教你如何开始使用 DOTS,这是 Unity 的新技术,允许创建高性能游戏,在场景中涉及数千个对象的复杂游戏玩法。

要充分利用本书

你将通过本书的章节开发一个完整的项目,虽然你可以只阅读章节,但我强烈建议你在阅读本书的过程中练习这个项目中的所有步骤,以便获得在这里正确学习概念所需的经验。章节已被设计成你可以自定义游戏,而不是创建书中显示的确切游戏。然而,我建议不要偏离主要思想太远。

项目文件已按章节分割到各自的文件夹中,并按累积方式设计,每个文件夹只包含章节中引入的新文件或更改的文件。这意味着,例如,如果一个文件自第一章以来没有更改,你将不会在第二章及以后的章节中找到它;那些章节将仅使用第一章中引入的文件。这允许你看到我们在每个章节中做了哪些更改,轻松地识别所需更改,并且如果你由于某种原因无法完成,例如,第三章,你只需在第三章的基础上继续第四章的步骤。此外,请注意,第十五章第十九章将有两个版本的文件:C#版本和视觉脚本版本。

本书涵盖的软件/硬件 操作系统要求
Unity 2023.2 Windows, macOS X 或 Linux (任何)
Visual Studio 2023 Community Windows 或 macOS X (任何)
XCode 15 macOS X

虽然我们将看到如何使用 XCode 15,但它对于大多数章节不是必需的。此外,Linux 中还有 Visual Studio 的替代品,如 Visual Studio Code。

如果你使用的是本书的数字版,我们建议你亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助你避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

该书的代码包托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Unity-2023-Game-Development-Fourth-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835085714

约定使用

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“将下载的 WebStorm-10*.dmg 磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

<ui:Scroller high-value="100"
                direction="Horizontal"
                 value="42" /> 

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

<ui:Scroller high-value="100"
                **direction****=****"Horizontal"**
                 value="42" /> 

粗体:表示新术语、重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“从管理面板中选择系统信息。”

警告或重要提示如下所示。

小贴士和技巧如下所示。

联系我们

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

一般反馈:请将邮件发送至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您向我们报告。请访问 www.packtpub.com/submit-errata,点击提交勘误,并填写表格。

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

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

分享您的想法

一旦您阅读了 Hands-On Unity Game Development第四版,我们非常乐意听到您的想法!扫描下面的二维码直接进入本书的亚马逊评论页面并分享您的反馈。

Microsoft Teams 图片

packt.link/r/1835085717

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到处走?

你的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

    二维码图片

    packt.link/free-ebook/9781835085714

  2. 提交您的购买证明

  3. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱

第一部分

Unity 入门

掌握 Unity 编辑器的基础,从创建空白项目到制作带有原型和最终图形的场景。

本节包含以下章节:

  • 第一章踏上 Unity 之旅

  • 第二章制作场景和游戏元素

  • 第三章,*从蓝图到现实:使用地形和 ProBuilder 构建

  • 第四章无缝集成:导入和整合资源

第一章:开始你的 Unity 之旅

在本章中,我们将学习如何使用 Unity Hub 安装 Unity 并创建项目,Unity Hub 是一个管理不同 Unity 版本和项目(以及其他任务)的工具。Unity Hub 提供了访问社区博客、论坛、资源和学习门户的便捷途径;它还管理你的许可证,并允许管理不同的安装和项目。

具体来说,在本章中,我们将探讨以下主题:

  • 安装 Unity

  • 创建项目

首先,让我们来谈谈如何让 Unity 启动运行。如果你已经知道如何安装 Unity,请随意跳到第二章制作场景和游戏元素。如果你已经熟悉 Unity 的编辑器,你可以跳到第三章从蓝图到现实:使用地形和 ProBuilder 构建,我们将开始创建本书的项目。

安装 Unity

我们将从简单但必要的第一步开始:安装 Unity。这似乎是一个简单的第一步,但我们可以讨论正确的安装方式。在本节中,我们将探讨以下概念:

  • Unity 的技术要求

  • Unity 版本

  • 使用 Unity Hub 安装 Unity

首先,我们将讨论在电脑上运行 Unity 所需的条件。

Unity 的技术要求

要运行 Unity 2023 编辑器,你的电脑需要满足这里指定的要求:docs.unity3d.com/2023.2/Documentation/Manual/system-requirements.html

这里是对链接中指定内容的总结:

  • 如果你使用 Windows,你需要 Windows 10.7 版本 21H1(19043)或更高版本。Unity 只能在 64 位版本的这些系统上运行;除非你愿意使用 2017.x 之前的 Unity 版本,否则没有 32 位支持,但这超出了本书的范围。

  • 对于 Mac,你需要 Big Sur 11.0。

  • 对于 Linux,你需要确切的 Ubuntu 20.04 或 22.04 版本。

关于 CPU 的要求:

  • 你的 CPU 需要支持 64 位

  • 你的 CPU 需要支持 SSE2(大多数 CPU 都支持)

  • 对于搭载 Apple 硅的 Macs,需要 M1 或更高版本。对于 Intel Macs,任何支持 x64 和 SSE2 的型号都将工作。

最后,关于显卡,以下是受支持的型号:

  • 在 Windows 上,我们需要一款支持 DirectX 10、11 或 12 的显卡(大多数现代显卡都支持)

  • 在 Mac 上,任何支持 Metal 的 Intel 或 AMD GPU 都足够了

  • 在 Linux 上,支持 OpenGL 3.2 或任何更高版本,或者来自 Nvidia 和 AMD 的 Vulkan 兼容卡

注意,这些不是用户玩你的游戏所需的要求,而是你使用编辑器所需的要求。有关用户玩你的游戏所需的要求,请参阅以下文档:docs.unity.cn/ru/2021.1/Manual/system-requirements.html#player

既然我们已经知道了要求,让我们来讨论 Unity 的安装管理系统。

Unity 版本

在这本书撰写的时候,Unity 宣布了他们版本系统的转变。当前的版本,Unity 2023.2,将是 2023 系列的最终迭代。下一个版本,原本应该是 2023.3 LTS,将改为以 Unity 6 的形式发布。这次过渡标志着LTS长期支持)发布周期的结束,这迫使 Unity 每年发布几个版本。展望未来,我们预计将按顺序推出 Unity 6.1、6.2、6.3 等版本,每个版本都提供了一个稳定且功能丰富的平台。

LTS 版本的好处是它们计划在两年内每两周更新一次,以修复 Unity 新主要版本的错误。这就是为什么大多数公司坚持使用 LTS 版本的引擎:因为其稳定性和长期支持。在这本书中,我们将使用 2023.1 来探索引擎的新功能,但在开发商业游戏标题时,请考虑坚持使用 LTS 版本。

考虑到这一点,你可能需要安装几个版本的 Unity,以防你使用不同版本的项目。你可能想知道为什么你不能为每个项目都使用 Unity 的最新版本,但那有一些问题。

在 Unity 的新版本中,通常会有很多关于引擎工作方式的改变,因此你可能需要重新制作很多游戏的部分来升级它,包括第三方插件。升级整个项目可能需要很多时间,这可能会推迟发布日期。也许你需要更新中包含的特定功能。在这种情况下,升级的成本可能是值得的。在需要多年维护和更新的项目中,开发者通常会坚持使用现有版本,只有在新的更新包含必要功能时才会升级。

使用不同版本的 Unity 制作的不同项目,以及安装和更新新的 Unity 版本,过去一直是个大麻烦。因此,Unity Hub被创建出来帮助我们解决这个问题,并且它已经成为安装 Unity 的默认方式。尽管如此,安装 Unity 并不需要它,但现在我们会保持简单并使用它。让我们更深入地了解一下。

使用 Unity Hub 安装 Unity

Unity Hub 是我们安装 Unity 之前将安装的一个小软件。它集中管理你所有的 Unity 项目和安装。你可以从官方 Unity 网站获取它。下载步骤会频繁变化,但在撰写本书时,你需要执行以下步骤:

  1. 前往unity.com

  2. 点击以下截图所示的计划和定价按钮:

图 1.1:Unity 网站上计划和定价按钮

  1. 点击学生和爱好者标签页;然后,在个人部分下,点击以下截图所示的开始按钮:

图 1.2:选择个人/免费许可证

  1. 滚动到标有1. 下载 Unity Hub的部分,并根据您的操作系统点击下载按钮。对于 Windows,点击Windows 下载,对于 Mac,点击Mac 下载。对于 Linux,有一个Linux 说明按钮,其中包含有关在该平台上安装的更多信息,但本书不会涵盖 Linux 上的 Unity:

图 1.3:开始下载

  1. 执行下载的安装程序。

  2. 按照安装程序的说明操作,这主要涉及一路点击下一步直到结束。

现在我们已经安装了 Unity Hub,我们必须使用它来安装特定的 Unity 版本。你可以按照以下步骤操作:

  1. 启动 Unity Hub。

  2. 如果被提示安装 Unity 版本和/或创建许可证,请使用相应的跳过按钮(这可能会根据 Unity Hub 版本而有所不同)跳过这些步骤。这种方式安装 Unity 和许可证仅在第一次运行 Unity Hub 时可用,但我们将学习一种在初始设置之后也有效的方法。

  3. 登录到您的账户,点击登录按钮:

图 1.4:登录 Unity Hub

  1. 在这里,您还可以选择创建一个 Unity 账户,如果您还没有的话,如图所示,在以下截图中的 Unity 登录提示中出现的创建一个链接中:

图 1.5:登录 Unity Hub

  1. 按照安装程序的步骤操作,然后你应该会看到一个类似于下一张截图的屏幕。如果它不是相同的,请尝试点击屏幕左上角的学习按钮:

图 1.6:Unity Hub 窗口

  1. 点击安装按钮,检查是否列出了Unity 2023

  2. 如果没有,请点击右上角的安装编辑器按钮。这将显示可以在此安装的 Unity 版本列表:

图 1.7:可安装的 Unity 版本

  1. 在这里,你会看到三个标签页。官方发布版包含已发布的每个主要版本的最新版本。预发布版包含 Unity 的 alpha 和 beta 版本,因此你可以参与这些项目并在它们正式发布之前测试新功能。存档包含指向Unity 下载存档的链接,其中包含发布的每个 Unity 版本。例如,撰写本文时的官方发布版本是 2023.2.4f1,但如果看到更高版本,你可以从存档中安装正确的版本。

  2. 官方发布版标签页中找到 Unity 2023.2(或者,如果您找不到,在存档标签页中)。

  3. 点击Unity 2023.2.XXf1右侧的安装按钮,其中 XX 将根据最新可用版本而变化。存档包含一个链接到 Unity 下载存档,其中包含 Unity 发布的每个版本。截至本文撰写时,官方版本是 2023.2.4f1,但如果你遇到更新的版本,你可以从存档中安装正确的版本。

  4. 将会弹出一个模块选择窗口。请确保Visual Studio功能被勾选。虽然这个程序在 Unity 中工作不是必需的,但本书后面我们会用到它。如果你已经安装了 C# IDE,可以自由跳过这一步。

  5. 现在,点击继续按钮:

图 1.8:选择 Visual Studio

  1. 接受 Visual Studio 的条款和条件,然后点击安装

    图 1.9:接受 Visual Studio 的条款和条件

    重要的是要注意,Visual Studio 是我们将在第五章释放 C#和视觉脚本的力量中使用的程序,来创建我们的代码。我们现在不需要其他 Unity 功能,但如果你需要,稍后可以回来安装它们。

  2. 你将看到所选的 Unity 版本正在下载和安装。等待这个过程完成。如果你看不到,点击下载按钮重新打开它:

图 1.10:当前活跃的 Unity Hub 下载

  1. 如果你决定安装 Visual Studio,Unity 安装完成后,Visual Studio 安装程序将自动执行。它将下载一个安装程序,该安装程序将下载并安装 Visual Studio Community:

图 1.11:安装 Visual Studio

  1. 为了确认一切正常,你必须看到所选的 Unity 版本在 Unity Hub 的安装列表中:

图 1.12:可用的 Unity 版本

现在我们已经通过 Unity Hub 在我们的计算机上安装了 Unity 和 Visual Studio,在使用 Unity 之前,我们需要通过以下步骤获取并安装免费许可证,使其工作:

  1. 在 Unity Hub 右上角点击管理许可证按钮。如果看不到,点击左上角的账户图标,然后在那里点击添加许可证

图 1.13:点击以获取免费许可证的添加许可证按钮

  1. 许可列表窗口中点击添加按钮:

图 1.14:许可列表窗口的添加按钮

  1. 点击获取免费个人许可证按钮:

图 1.15:获取免费个人许可的选项

  1. 如果你同意条款和条件,请点击同意并获取个人版许可证按钮进行阅读和接受:

图 1.16:接受条款和条件的按钮

有了这个,我们现在在我们的账户中有了使用 Unity 的有效许可证。记住,前面的步骤在新版本的 Unity Hub 中可能会有所不同,所以只需尽量遵循 Unity 设计的流程——大多数情况下,它是直观的。

现在是时候使用 Unity 创建一个项目了。

创建项目

现在我们已经安装了 Unity,我们可以开始创建我们的游戏了。要做到这一点,我们首先需要创建一个项目,它基本上是一个包含你的游戏将包含的所有文件的文件夹。这些文件被称为资产,它们有不同的类型,例如图像、音频、3D 模型、脚本文件等等。在本节中,我们将了解如何管理项目,并解决以下概念:

  • 创建项目

  • 项目结构

让我们首先学习如何创建一个空白项目,以便在本书中开始开发我们的第一个项目。

创建项目

与 Unity 安装一样,我们将使用 Unity Hub 来管理项目。我们需要遵循以下步骤来创建一个:

  1. 打开 Unity Hub,点击项目按钮,然后点击新建项目

图片

图 1.17:在 Unity Hub 中创建新项目

  1. 注意,如果你通过 Unity Hub 安装了多个 Unity 版本,你可能需要从 UI 顶部的下拉菜单中选择适当的版本,以确保使用你之前安装的 2023.1 版本。

图片

图 1.18:选择合适的 Unity 版本

  1. 选择3D (URP)模板,因为我们将会创建一个使用简单图形的 3D 游戏,并准备在 Unity 能够运行的任何设备上运行,所以URP(或通用渲染管线)是更好的选择。在第十章材料炼金术:使用 URP 和 Shader Graph 实现震撼视觉效果中,我们将详细讨论为什么。

  2. 如果你看到一个下载模板按钮,点击它;如果没有,这意味着你已经有模板了:图片

    图 1.19:下载 3D URP 模板

    我仍然记得 2009 年在 Unity 2.6 中看到的第一个模板项目,当时 Unity 版本号还没有与发布年份匹配。它是一个岛屿,展示了地形和水系统。当靠近你时,火烈鸟会避开你,四处走动非常有趣。遗憾的是,我们选择的模板项目不会像那个一样令人难忘,但仍然是一个很好的开始。

  3. 选择项目名称和位置,然后点击创建项目

图片

图 1.20:选择通用渲染管线模板

  1. Unity 将创建并自动打开项目。这可能需要一些时间,但之后你将看到一个类似于以下截图的窗口。你可能看到的是深色主题编辑器,但为了更好的清晰度,我们将全书使用浅色主题。你可以自由地保持深色主题:

图片

图 1.21:Unity 编辑器窗口

  1. 关闭窗口,然后返回 Unity Hub 并从列表中选择项目以再次打开:

图片

图 1.22:重新打开项目

现在我们已经创建了项目,让我们来探索其结构。

项目结构

我们刚刚打开了 Unity,但我们将不会在下一章开始使用它。现在,是时候看看项目文件夹结构是如何组成的了。为了做到这一点,我们需要打开我们创建项目的文件夹。如果你不记得在哪里,你可以这样做:

  1. 右键单击位于编辑器底部部分的项目面板中的Assets文件夹。

  2. 在编辑器的底部部分,点击项目面板中的Assets文件夹的显示在资源管理器中选项(如果你使用的是 Mac,该选项称为在 Finder 中显示)。以下截图说明了这一点:

图片

图 1.23:在资源管理器中打开项目文件夹

  1. 然后,你会看到一个类似于这样的文件夹结构(某些文件或文件夹可能会有所不同):

图片

图 1.24:Unity 项目文件夹结构

现在我们已经使用 Unity Hub 创建了并打开了第一个 URP 项目,我们可以在 Unity Hub 的项目选项卡中再次找到它。从那里,我们可以在任何时候再次打开它。

如果你想将这个项目移动到另一台 PC 或发送给同事,你只需将这些文件压缩成 ZIP 文件发送即可,但并非所有文件夹在所有时候都是必要的。重要的文件夹是AssetsPackagesProjectSettingsAssets将保存我们为游戏创建和使用的所有文件,因此这是必须的。我们还将配置不同的 Unity 系统,以适应我们的游戏;与此相关的所有设置都在ProjectSettingsUserSettings文件夹中。最后,我们将安装不同的 Unity 模块或包以扩展其功能,因此Packages文件夹将保存我们正在使用的那些。

如果你需要将项目移动到其他地方或添加到版本控制系统,不需要复制其余的文件夹,但至少让我们讨论一下Library文件夹是什么,特别是考虑到它通常非常大。Unity 需要将我们将要使用的文件转换为它自己的格式才能操作;一个例子是音频和图形。Unity 支持MPEG 音频层 3MP3)、波形音频文件格式WAV)、可移植网络图形PNG)和联合图像专家小组JPG)文件(以及更多),但在使用它们之前,它们需要被转换为 Unity 的内部格式,这个过程称为导入资源。那些转换后的文件将位于Library文件夹中。如果你没有复制那个文件夹,Unity 将简单地从Assets文件夹中取原始文件并完全重新创建Library文件夹。这个过程可能需要时间,项目越大,所需时间越长。

请记住,当你正在处理项目时,你希望保留 Unity 创建的所有文件夹,所以在你处理项目时不要删除任何文件夹,但如果你需要移动整个项目,你现在确切地知道你需要带什么。

摘要

在本章中,我们回顾了 Unity 版本控制系统的工作方式。我们还看到了如何使用 Unity Hub 安装和管理不同的 Unity 版本。最后,我们使用相同的工具创建了并管理了多个项目。我们将大量使用 Unity Hub,因此了解如何最初使用它非常重要。现在,我们已经准备好深入 Unity 编辑器了。

在下一章中,我们将开始探索基本的 Unity 工具,为编写我们的第一个关卡原型打下基础。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第二章:制作场景和游戏元素

在本章中,我们将开发一些 Unity 的基础知识,以便编辑项目,我们将学习如何使用几个 Unity 编辑器窗口来操作我们的第一个场景及其对象。我们还将学习如何创建和组成对象或 GameObject,以及如何使用层次结构和预制件来管理复杂场景。最后,我们将回顾如何正确保存所有工作以便稍后继续工作。

具体来说,在本章中,我们将考察以下概念:

  • 操作场景

  • GameObjects 和组件

  • 理解对象层次结构

  • 使用预制件管理 GameObject

  • 保存场景和项目

操作场景

场景是我们项目中几种类型的文件之一(也称为资产)。其他类型的文件包括代码脚本、音频文件、3D 模型和纹理等。根据项目类型,“场景”可以用于不同的事情,但最常见的用例是将你的游戏分成整个部分,最常见的包括以下内容:

  • 主菜单

  • 第 1 关,第 2 关,第 3 关等等。

  • 胜利屏幕和失败屏幕

  • 启动屏幕和加载屏幕

在本节中,我们将涵盖与场景相关的以下概念:

  • 场景的目的

  • 场景视图

  • 将我们的第一个 GameObject 添加到场景中

  • 导航场景视图

  • 操作 GameObject

因此,让我们看看这些概念中的每一个。

场景的目的

将你的游戏分成场景的想法是让 Unity 只处理和加载场景所需的数据。比如说你在主菜单;在这种情况下,你将只有主菜单需要的纹理、音乐和对象被加载到随机存取存储器RAM),即设备的主要内存。在这种情况下,如果你的游戏不需要加载第 10 关的 Boss,就没有必要加载它。这就是为什么有加载屏幕,只是为了填补在卸载一个场景所需的资产和加载另一个场景所需的资产之间的时间。也许你正在想,像侠盗猎车手这样的开放世界游戏在你四处游荡时没有加载屏幕,但实际上它们在移动时会在后台加载和卸载世界的一部分,这些部分是设计成可以相互连接的不同场景。

主菜单和常规关卡场景之间的区别在于它们拥有的对象(在 Unity 术语中也称为GameObject)。在菜单中,你会找到背景、音乐、按钮和标志等对象,而在关卡中,你将拥有玩家、敌人、平台、生命值盒子等等。因此,你的场景意义取决于放入其中的 GameObject。但我们是怎样创建场景的呢?让我们从场景视图开始。

场景视图

当你打开 Unity 项目时,你会看到 Unity 编辑器。它将由几个窗口面板组成,每个面板都帮助你改变游戏的不同方面。在本章中,我们将查看帮助你创建场景的窗口。以下截图展示了 Unity 编辑器:

图 2.1:Unity 编辑器

如果你之前曾经编程过任何类型的应用程序,你可能已经习惯了有一个起始函数,比如Main,在那里你开始编写代码来创建应用程序所需的几个对象。如果你在开发一个游戏,你可能会在那里创建场景中所有的对象。这种方法的缺点是,为了确保所有对象都正确创建,你需要运行程序来查看结果,如果某个地方放错了,你需要手动更改对象的坐标,这是一个缓慢且痛苦的过程。幸运的是,在 Unity 中,我们有场景视图,以下截图展示了其一个示例:

计算机截图 自动生成描述

图 2.2:场景视图

这个窗口是经典WYSIWYG所见即所得)概念的实现。在这里,你可以创建对象并将它们放置在场景的任何地方,所有这些都是在场景预览中完成的,你可以看到玩家玩游戏时场景将如何看起来。然而,在我们学习如何使用这个场景之前,我们需要场景中有一个对象,所以让我们创建我们的第一个对象。

将我们的第一个 GameObject 添加到场景中

在创建项目时选择的模板项目包含一个空白场景,可以开始工作,但让我们创建我们自己的空场景来看看如何自己完成。为此,你可以简单地使用文件 | 新场景菜单来创建一个空的新场景,以下截图展示了这一点:

图 2.3:创建新场景

点击新场景后,你会看到一个选择场景模板的窗口;在这里,选择基本(URP)模板。模板定义了新场景将包含哪些对象,在这种情况下,我们的模板将包含一个基本光源和一个摄像机,这对我们想要创建的场景将很有用。一旦选择,只需点击创建按钮:

计算机截图 自动生成描述

图 2.4:选择场景模板

现在我们已经有一个空场景了,让我们向其中添加 GameObject。我们将在整本书中学习创建 GameObject 的几种方法,但就现在,让我们先使用 Unity 为我们提供的几个基本模板。为了创建它们,我们需要打开 Unity 窗口顶部的GameObject菜单,它将显示几个模板类别,如3D Object2D ObjectEffects等,以下截图展示了这一点:

图 2.5:创建一个立方体

3D 对象类别下,我们将看到几个 3D 基本形状,如立方体球体圆柱体等,虽然使用它们不如使用漂亮的下载 3D 模型那么令人兴奋,但请记住,我们目前只是在原型化我们的关卡。这被称为灰盒建模,意味着我们将使用大量的原型基本形状来建模我们的关卡,以便我们可以快速测试它,看看我们的想法是否足够好,可以开始将其转换为最终版本的工作。

我建议您选择立方体对象开始,因为它是一个多功能的形状,可以代表许多对象。所以,现在我们有一个可以编辑的场景和对象,我们需要学习使用场景视图做的第一件事就是导航场景。

导航场景视图

为了操作场景,我们需要学习如何移动以从不同角度查看结果。有几种导航场景的方法,所以让我们从最常见的一种开始,即第一人称视角。这种视角允许你使用类似第一人称射击游戏的导航方式在场景中移动,使用鼠标和WASD键。要这样导航,你需要按住并保持右鼠标按钮,同时进行以下操作:

  • 将鼠标移动到旋转相机围绕其当前位置

  • 按下WASD键来移动相机的位置,始终按住右键

  • 您也可以按Shift键来加快移动速度

  • 按下QE键来上下移动

另一种常见的移动方式是点击一个对象来选择它(选中的对象将有一个橙色的轮廓),然后按F键来聚焦于它,使场景视图相机立即移动到一个可以更近距离观察该对象的位置。

之后,我们可以在 Windows 上按住并保持左Alt键,或在 Mac 上按Option键,同时点击左鼠标,最终开始移动鼠标并在对象周围“环绕”。这将允许您从不同的角度查看焦点对象,以检查其每个部分是否都放置得当,如下面的截图所示:

包含截图和设计的图片  描述由低置信度自动生成

图 2.6:选择一个对象

现在我们可以在场景中自由移动,我们可以开始使用场景视图来操作游戏对象。

操作游戏对象

场景视图的另一个用途是操作对象的定位。为了做到这一点,我们首先需要选择一个对象,然后按场景视图左上角的变换工具。一旦选择了对象,您也可以按键盘上的Y键来激活相同的变换工具:

一个标志的特写  描述由低置信度自动生成

图 2.7:变换工具

这将显示所选对象上所谓的变换工具工具是一种叠加在所选对象之上的视觉工具,用于修改其不同方面。在变换工具的情况下,它允许我们改变对象的位置、旋转和缩放,如图图 2.8所示。如果你没有看到球体外的立方形箭头,请不要担心——我们很快就会启用它们:

包含截图、圆形、艺术描述自动生成的图片

图 2.8:变换工具

让我们开始移动对象,这是通过在工具的球体内拖动红色、绿色和蓝色箭头来实现的。当你这样做的时候,对象将沿着所选轴移动。这里一个有趣的概念是探索这些箭头颜色的含义。如果你注意观察场景视图的右上角,你会看到一个轴工具,它作为这些颜色含义的提醒,如图下所示:

包含截图、设计描述自动生成的图片

图 2.9:轴工具

计算机图形学使用经典的 3D笛卡尔坐标系来表示对象的定位。红色与对象的x轴相关联,绿色与y轴相关联,蓝色与z轴相关联。

但每个轴代表什么?如果你习惯于使用像 Maya、Blender 或 3DS Max 这样的其他 3D 制作程序,这可能会不同,但在 Unity 中,z轴代表前向向量,这意味着箭头指向对象的前面;x轴是右向向量,而y轴代表向上向量

这些方向被称为局部坐标,这是因为每个对象都可以以不同的方式旋转,这意味着每个对象都可以根据其方向将其前向、向上和向右的向量指向其他地方。当在章节的理解对象层次结构部分稍后使用时,局部坐标将更有意义,所以请耐心等待,但现在讨论全局坐标是值得的。想法是有一个单一的起点(零点)和一组共同的向前、向右和向上轴,这些轴在场景中是通用的。这样,当我们说一个对象的全局位置是5,0,0时,我们知道我们指的是从全局零位置开始沿全局x轴延伸 5 米的坐标。全局轴是你在之前提到的右上角轴工具中看到的那些。

我们刚才提到,位置(5,0,0)意味着x轴上 5 米,这暗示 Unity 单位系统是米。虽然 Unity 的几个系统如物理和音频遵循这个假设(1 单位=1 米),但这并不是强制性的单位系统。有几种方法可以改变它,为了方便缩放世界,但在本书中,我们将坚持米测量系统。

为了确保我们使用局部坐标,即我们将沿着对象的局部轴移动对象,请确保在场景视图中激活了局部模式,如下面的屏幕截图所示:

手机屏幕截图  描述由中等置信度自动生成

图 2.10:切换轴心和局部坐标

如果右键按钮显示的是全局而不是局部,只需点击它,然后从下拉选项中选择局部。顺便说一句,尽量保持左键按钮为轴心。如果它显示的是中心,点击并选择轴心。对象的轴心不一定是它的中心,这完全取决于我们使用的 3D 模型,以及作者指定对象旋转中心的位置。例如,一辆汽车可能有它的轴心在它的后轮中间,所以当我们旋转时,它将尊重真实汽车的旋转中心。基于对象的轴心进行编辑将简化我们对如何在第六章中通过 C#脚本旋转对象的理解。现在,我们已经启用了局部坐标,你应该看到图 2.8中看到的立方形箭头;我们将在稍后使用它们来缩放立方体。

我知道——我们正在编辑一个立方体,所以没有明确的前面或右面,但当你处理真实的 3D 模型,如汽车和角色时,它们肯定会有这些面,并且它们必须与这些轴正确对齐。如果将来你意外地将一辆汽车导入 Unity,并且汽车的前面沿着x轴,你需要将模型沿z轴对齐,因为我们将要创建的用于移动对象的代码将依赖于这个约定。

现在,让我们使用这个变换工具来旋转对象,使用围绕它的三个彩色圆圈。例如,如果你点击并拖动红色圆圈,你将沿着x轴旋转对象。如果你想水平旋转对象,根据我们之前讨论的颜色编码,你可能选择x轴——用于水平移动的那个轴——但是,很遗憾,这是错误的。看待旋转的一个好方法是像摩托车加速器:你需要拿起它并滚动它。如果你这样旋转x轴,你将使对象上下旋转。所以,为了水平旋转,你需要使用绿色圆圈或y轴。这个过程在下面的屏幕截图中有说明:

包含截图、设计、艺术描述自动生成的图片

图 2.11:旋转对象

最后,我们有缩放,我们有两种方法可以实现这一点,其中一种是通过图 2.8中显示的变换工具中心的灰色立方体。这允许我们通过点击并拖动该立方体来改变对象的大小。现在,由于我们想要原型化一个简单的关卡,有时我们想要拉伸立方体来创建,例如,一列或一个平坦的地板,这就是第二种方法出现的地方。

如果你点击并拖动翻译箭头前面的彩色立方体而不是中心灰色的立方体,你会看到我们的立方体如何沿着这些轴拉伸,从而允许你改变对象的形状。如果你看不到那些立方体形状的箭头,记得要启用前面在本节中提到的局部坐标。

拉伸的过程在下面的屏幕截图中有说明:

包含截图、设计描述自动生成的图片

图 2.12:缩放对象

记住,如果你希望的话,也可以使用中间的变换工具中的相同灰色立方体来同时缩放所有轴。这被称为均匀缩放

最后,这里需要考虑的是,由于它们最初的设计方式,几个对象可以具有相同的缩放值但不同的尺寸。缩放是我们可以应用于对象原始尺寸的一个乘数,因此一个比例1的建筑和一辆车都是完全合理的;它们相对于彼此的相对大小看起来是正确的。这里的主要启示是,缩放不是尺寸,而是一种乘以它的方式。

考虑到在许多情况下缩放对象通常是不良的做法。在你的场景最终版本中,你将使用适当大小和比例的模型,并且它们将以模块化的方式设计,以便你可以将它们相互连接。如果你缩放它们,可能会发生一些不好的事情,比如纹理被拉伸并变得像素化,以及不再正确连接的模块。这个规则有一些例外,比如在森林中放置许多相同树木的实例,并稍微改变其比例以模拟变化。此外,在灰色框的情况下,将立方体改变比例以创建地板、墙壁、天花板、柱子等是完全可行的,因为最终,这些立方体将被真实的 3D 模型所取代。

这里有一个挑战!创建一个由地板、三面普通墙壁和带有门洞的第四面墙壁(三个立方体)组成的房间,不需要屋顶。在下一张图片中,你可以看到它应该是什么样子:

包含截图、设计、水盆描述自动生成的图片

图 2.13:房间任务完成

记忆

我记得当我刚开始制作游戏时,我大多数场景都是用盒子做的。虽然我们在这本书中肯定会做得更好,但仍然有非常巧妙的方法只使用这个简单的形状。以游戏托马斯独自一人为例,它只使用了盒子,通过巧妙的光照效果看起来很漂亮。

现在我们已经可以编辑对象的位置了,让我们看看如何编辑其所有其他方面。

GameObjects 和组件

我们提到我们的项目由assets(项目文件)组成,一个场景(它是一种特定的资产)由 GameObject 组成;那么,我们如何创建一个对象呢?通过组件的组合。

在本节中,我们将介绍与组件相关的以下概念:

  • 理解组件

  • 操作组件

让我们先讨论一下组件是什么。

理解组件

组件是构成 GameObject 的几个部分之一;每个组件负责对象的不同功能。Unity 已经包含了一些组件,可以解决不同的任务,例如播放声音、渲染网格、应用物理效果等;然而,尽管 Unity 有大量的组件,我们迟早需要创建自定义组件。

在下一张图片中,你可以看到当我们选择一个 GameObject 时 Unity 向我们展示的内容:

图片

图 2.14:检查器面板

如果我们需要猜测前面截图中的检查器面板现在做什么,我们可以说它显示了通过层次结构、你可以看到所有已经放置在当前场景中的对象的菜单,或者通过场景视图选择的选定对象的全部属性,并允许我们配置这些选项以改变对象的行为(即位置和旋转,是否投射阴影等)。

这是真的,但我们遗漏了一个关键元素:这些属性不属于对象;它们属于对象的组件。我们可以在一组属性之前看到一些加粗的标题,例如变换盒子碰撞器等。这些都是对象的组件。

在这种情况下,我们的对象有一个变换、一个网格过滤器、一个网格渲染器和一个盒子碰撞器组件,所以让我们回顾每一个。

变换只包含对象的位置、旋转和缩放,它本身并不做任何事情——它只是我们游戏中的一个点——但是当我们向对象添加组件时,这个位置开始具有更多的意义。这是因为一些组件将与变换和其他组件交互,每个组件都会影响另一个。

这些不同组件之间相互作用的例子可以是网格过滤器网格渲染器的情况,这两个组件都负责渲染 3D 模型。网格渲染器将渲染由网格过滤器变换组件中指定的位置指定的 3D 模型,也称为网格,因此网格渲染器需要从其他组件获取数据,没有它们无法工作。

另一个例子是盒子碰撞器。这代表对象的物理形状,因此当物理计算对象之间的碰撞时,它会检查该形状是否与指定在变换组件中的其他形状发生碰撞。

我们将在本书的后面部分探讨渲染和物理,但本节的要点是 GameObject 是一个组件集合,每个组件都为我们的对象添加特定的行为,并且每个组件都与其他组件交互以完成所需任务。为了进一步强化这一点,让我们看看我们如何将一个立方体转换为球体,该球体将由于物理施加的重力而落下。

操作组件

编辑对象组件的工具是检查器。它不仅允许我们更改组件的属性,还让我们可以添加和删除组件。在这种情况下,我们想要将一个立方体转换为球体,因此需要更改这些组件的几个方面。

我们可以先从改变对象的视觉形状开始,因此我们需要更改渲染模型或网格。指定要渲染的网格的组件是网格过滤器组件。如果我们查看以下图示,我们可以看到一个名为网格的属性,其右侧有一个小圆圈和一个小点:

图片 B21361_02_15.png

图 2.15:网格过滤器组件

如果您看不到特定的属性,例如我们刚才提到的网格,请尝试点击组件名称左侧的三角形。这样做将展开和折叠所有组件属性。

如果我们点击位于网格属性右侧、带有圆圈和点的按钮,将弹出选择网格窗口,允许我们选择几个网格选项。在这种情况下,选择球体网格。在未来,我们将向我们的项目中添加更多 3D 模型,因此窗口将具有更多选项。

网格选择器在以下屏幕截图中显示:

计算机屏幕截图,自动生成描述

图 2.16:网格选择器

好的——现在对象看起来像一个球体,但它会像球体一样表现吗?让我们来看看。为了做到这一点,我们可以在我们的球体上添加一个名为刚体的组件,这将给它添加物理属性。我们将在第七章碰撞与健康:精确检测碰撞中更多地讨论刚体和物理,但现在让我们专注于基础知识。

要将刚体添加到我们的球体上,我们需要在检查器的底部点击添加组件按钮。它将显示一个组件选择器窗口,其中包含许多类别;在这种情况下,我们需要点击物理类别。窗口将显示所有物理组件,在那里我们可以找到刚体。另一个选项是在窗口顶部的搜索框中输入Rigidbody。以下截图说明了如何添加组件:

图 2.17:添加组件

如果您在编辑器的顶部中间部分点击播放按钮,您可以使用游戏面板测试您的大球物理效果。当您点击播放时,该面板将自动聚焦,并显示玩家将如何看到游戏。播放控制按钮如图下截图所示:

图 2.18:播放控制

在这里,您只需使用变换工具来旋转和定位您的相机,使其朝向我们的大球。这很重要,因为可能会出现的一个问题是,在播放模式下您可能什么也看不到,这可能是因为游戏相机没有指向我们大球所在的位置。当您移动时,您可以在场景窗口的右下角查看小预览,以检查新的相机视角。如果您已经选择了相机,这就是预期的行为。另一个选择是在层次结构中选择相机,并使用快捷键Ctrl + Shift + F(或在 Mac 上为Command + Shift + F)。相机预览如图下截图所示:

计算机屏幕截图,自动生成描述,中等置信度

图 2.19:相机预览

现在,为了测试物理碰撞是否执行正确,让我们创建一个立方体,将其缩放成斜坡的形状,并将这个斜坡放在我们的大球下方,如图所示:

包含截图、月亮和大球的图片,自动生成描述

图 2.20:球和斜坡对象

如果现在您点击播放,您将看到球体以奇怪的方式与我们的斜坡碰撞。它看起来像是在弹跳,但实际上并非如此。如果您展开球体的盒子碰撞器组件,您将看到即使我们的物体看起来像是一个球体,绿色的盒子工具显示我们的球体在物理世界中实际上是一个盒子,如图下截图所示:

图 2.21:具有球形图形和盒子碰撞器的对象

现在,视频卡(GPU)可以处理渲染高度详细的三维模型(具有高多边形计数的模型),但物理系统是在中央处理单元CPU)中执行的,并且它需要进行复杂的计算以检测碰撞。为了在我们的游戏中获得良好的性能,它至少需要达到每秒 30 帧(FPS),这是行业接受的最低标准,以提供平滑的体验。物理系统考虑到了这一点,因此使用简化的碰撞形状,这些形状可能与玩家在屏幕上看到的实际形状不同。

正是因为我们有Mesh Filter和不同类型的Collider组件分开——一个处理视觉形状,另一个处理物理形状。

再次强调,本节的目的不是深入探讨那些 Unity 系统,所以我们现在就继续前进。我们如何解决我们的球体看起来像盒子的问题呢?简单:通过修改我们的组件!在这种情况下,已经存在于我们的立方体 GameObject 中的Box Collider组件可以仅仅代表一个物理形状的盒子,与支持任何渲染形状的Mesh Filter不同。因此,首先,我们需要通过右键单击组件的标题并选择移除组件选项来移除它,如下面的截图所示:

截图

图 2.22:移除组件

现在,我们再次可以使用添加组件菜单来选择一个物理组件,这次选择Sphere Collider组件。如果你查看物理组件,你会看到其他类型的碰撞器,可以用来表示其他形状,但我们将在第七章中稍后讨论它们。

Sphere Collider组件可以在以下截图中看到:

截图

图 2.23:添加球体 Collider 组件

所以,如果你现在点击播放,你会看到我们的球体不仅看起来像一个球体,而且表现得像一个球体。记住:本书本节的主要思想是理解在 Unity 中,你可以通过添加、移除和修改组件来创建任何你想要的对象,而且我们将在整本书中做很多这样的事情。

现在,组件不是创建对象所需的唯一东西。复杂对象可能由几个子对象组成,所以让我们看看这是如何工作的。

理解对象层次结构

一些复杂对象可能需要被分解成子对象,每个子对象都有自己的组件。这些子对象需要以某种方式附加到主对象上,并协同工作以创建必要的对象行为。

在本节中,我们将介绍与对象相关的以下概念:

  • 对象的父子关系

  • 可能的用途

让我们首先了解如何创建对象之间的父子关系。

对象的父子关系

父对象是指将一个对象设置为另一个对象的子对象,这意味着这些对象将相互关联。发生的一种关系是Transform 关系,这意味着子对象将受到父对象的Transform影响。简单来说,子对象将跟随父对象,就像它被附着在上面一样。例如,想象一个头上戴着帽子的玩家。帽子可以是玩家头部的子对象,使帽子在它们附着时跟随头部。

为了尝试这个功能,让我们创建一个代表敌人的胶囊体和一个代表敌人武器的立方体。记住,为了做到这一点,你可以使用GameObject | 3D Object | CapsuleCube选项,然后使用Transform工具来修改它们。以下截图展示了胶囊体和立方体的示例:

包含截图的图片,自动生成描述

图 2.24:代表敌人和武器的胶囊体和立方体

如果你移动敌人对象(胶囊体),武器(立方体)将保持其位置,不会跟随我们的敌人。因此,为了防止这种情况,我们可以在Hierarchy窗口中简单地拖动武器到敌人对象上,如下截图所示:

计算机截图,自动生成描述,置信度中等

图 2.25:将立方体武器设置为胶囊角色的父对象

现在,如果你移动敌人,你会看到枪支随着敌人移动、旋转和缩放。所以,基本上,枪支的Transform也具有敌人 Transform 组件的效果。

现在我们已经进行了一些基本的父对象设置,让我们探索其他可能的用途。

可能的用途

除了创建复杂对象之外,还有其他一些父对象的使用方法。它的另一个常见用途是组织项目层次结构。目前,我们的场景很简单,但随着时间的推移,它将逐渐增长,因此跟踪所有对象将变得困难。为了防止这种情况,我们可以创建空的 GameObject(在GameObject | Create Empty),它们仅具有 Transform 组件,充当容器,将对象放入其中以组织场景。请谨慎使用,因为过度使用会导致性能成本。通常,在组织场景时,有一到两个父对象级别是合适的,但超过这个级别可能会影响性能。请考虑,你可以——并且将会——为创建复杂对象进行更深层次的父对象设置;所提出的限制仅适用于场景组织。

为了改进我们之前的示例,将敌人复制几次散布在场景周围,创建一个空的 GameObject,命名为Enemies,并将所有敌人拖入其中,使其充当容器。以下截图展示了这一过程:

包含文本和截图的图片,自动生成描述

图 2.26:将敌人分组到父对象中

父对象(Parenting)的另一种常见用途是改变物体的支点(或中心)。目前,如果我们尝试使用变换(Transform)工具旋转我们的枪,它将围绕其中心旋转,因为创建该立方体的开发者决定将中心放在那里。通常情况下,这是可以的,但让我们考虑一个需要使武器瞄准敌人视线方向的案例。在这种情况下,我们需要围绕武器手柄旋转武器;因此,对于这个立方体武器来说,就是手柄最靠近敌人的部分。问题在于我们无法改变物体的中心,所以一个解决方案是创建另一个具有不同中心的“武器”3D 模型或网格,如果我们考虑其他可能的游戏玩法要求,如旋转武器拾取,这将导致大量武器的重复版本。我们可以通过父对象(Parenting)轻松解决这个问题。

策略是创建一个空的 GameObject,并将其放置在我们想要物体新支点所在的位置。之后,我们可以简单地拖动我们的武器到这个空 GameObject 中,从现在起,将这个空对象视为实际的武器。

如果你旋转或缩放这个武器容器,你会看到武器网格将应用这些变换到这个容器上,所以我们可以说武器的支点已经改变(实际上并没有,但我们的容器模拟了这种变化)。这个过程在下面的屏幕截图中展示:

计算机屏幕截图,描述自动生成,置信度中等

图 2.27:更改武器支点

现在,让我们继续探讨使用预制件管理 GameObject 的不同方法。

使用预制件管理 GameObject

在上一个例子中,我们在场景周围创建了敌人的许多副本,但这样做也带来了新的问题。让我们想象我们需要更改敌人并为其添加一个刚体(Rigidbody)组件,但由于我们有多个相同对象的副本,我们需要逐个选择它们并将相同的组件添加到所有副本上。也许以后我们还需要更改每个敌人的质量,所以同样,我们需要逐一检查每个敌人并做出更改,这时我们可以开始看到一种模式。一个解决方案可能是使用Ctrl键(在 Mac 上为Command键)选择所有敌人并一次性修改它们,但如果我们在其他场景中有敌人的副本,这个解决方案将毫无用处。所以,这就是预制件发挥作用的地方。

在本节中,我们将介绍与预制件相关的以下概念:

  • 创建预制件

  • 预制件实例关系

  • 预制件变体

让我们从讨论如何创建和使用预制件开始。

创建预制件

预制件(Prefab)是 Unity 的一个工具,允许我们将自定义对象,如我们的敌人,转换成一个定义它们如何创建的资产。我们可以使用它们轻松地创建自定义对象的新副本,而无需再次创建其组件和子对象。

为了创建一个 Prefab,您只需将自定义对象从 Hierarchy 窗口拖动到项目窗口,然后完成此操作后,您将在项目文件中看到一个新资产。项目窗口是您可以导航和探索所有项目文件的地方;因此,在这种情况下,您的 Prefab 是您创建的第一个资产。

现在,您只需将 Prefab 从项目窗口拖动到场景中,就可以轻松创建新的 Prefab 副本,如下图所示:

计算机截图  描述自动生成,置信度中等

图 2.28:创建 Prefab

现在,我们这里有个小问题。如果您注意 Hierarchy 窗口,您将看到原始 Prefab 对象以及所有带有蓝色名称的新副本,而创建 Prefab 之前的敌人将带有黑色名称。名称中的蓝色表示该对象是 Prefab 的 实例,意味着该对象是基于 Prefab 创建的。我们可以选择那些带有蓝色名称的对象,并在 Inspector 中的 Select 按钮上点击,选择创建该对象的原始 Prefab。这在下图中得到了说明:

图片

图 2.29:在 Hierarchy 中检测 Prefab

这里的问题是,非蓝色的敌人副本不是我们刚刚创建的 Prefab 的实例。我们可以通过在 Hierarchy 窗口中按住 Ctrl 键(Mac 上的 Command 键)点击所有敌人,然后右键单击它们以选择 Prefab | Replace... 选项来修复这个问题。最后,在出现的 Select GameObject 窗口中选择 Enemy Prefab,将它们转换为 Prefab 实例。这是最新 Unity 版本中的一项新功能。

图片

图 2.30:将常规 GameObject 转换为 Prefab 实例

您也可以用不同的 Prefab 替换原始 Prefab 的实例,并将 Prefab 实例转换回常规 GameObject。更多详情,请参阅视频 youtu.be/WOJzHz4sRyU 和 Unity 文档:docs.unity3d.com/Manual/UnpackingPrefabInstances.html

所有敌人副本不是 Prefab 实例似乎没有问题,但我们将看到为什么在下一节中这是一个问题,我们将探讨 Prefab 和它们的实例之间的关系。

Prefab 实例关系

预制体实例,即当将预制体拖动到场景中创建的 GameObject,与它有一个绑定,这有助于在预制体和实例之间轻松地回滚和应用更改。如果你对一个预制体进行一些修改,这些更改将自动应用到项目中所有场景的所有实例上,因此我们可以轻松地创建预制体的第一个版本,在项目的各个部分使用它,然后进行实验性更改。

为了练习这个功能,假设我们想要给敌人添加一个刚体组件,以便它们可以下落。为了做到这一点,我们可以在项目面板中双击预制体文件,进入预制体编辑模式,在那里我们可以独立于场景编辑预制体。

在这里,我们可以简单地选择预制体根对象(在我们的例子中是敌人)并为其添加刚体组件。之后,我们只需点击场景窗口左上角的场景按钮,就可以回到我们正在编辑的场景,现在,我们可以看到所有敌人的预制体实例都有一个刚体组件,如下面的截图所示:

图 2.31:预制体编辑模式

现在,如果我们改变一个预制体实例(场景中的那个)会发生什么?假设我们想让一个特定的敌人飞行,这样它们就不会受到重力的影响。我们可以通过简单地选择特定的预制体,并在刚体组件中取消勾选使用重力复选框来实现这一点。完成之后,如果我们玩游戏,我们会看到只有那个特定的实例会漂浮。这是因为预制体实例的变化会成为一个覆盖,即与原始预制体相比实例的差异集合。我们可以在检查器中看到使用重力属性被加粗,并且在其左侧显示一个蓝色条,表示它是原始预制体值的覆盖。让我们再取另一个对象,将其缩放属性更改为使其变大。同样,我们也会看到缩放属性被加粗,并且在其左侧出现一个蓝色条。以下截图显示了使用重力复选框:

图 2.32:突出显示使用重力作为覆盖

覆盖优先于预制体,所以如果我们更改原始预制体的缩放,具有缩放覆盖的那个不会改变,保持它自己的缩放版本,如下面的截图所示:

包含截图的图片 描述自动生成

图 2.33:具有缩放覆盖的预制体实例

我们可以通过在层次结构中选择预制件实例(场景中的,位于预制件编辑模式之外)后,在检查器中使用覆盖下拉菜单轻松定位实例的所有覆盖,定位我们的对象所做的所有更改。它不仅允许我们查看所有覆盖,还可以撤销我们不需要的任何覆盖,应用我们需要的覆盖。假设我们后悔了那个特定预制件缺乏重力的缺点——没问题!我们只需定位覆盖,并在点击具有覆盖的组件后使用撤销全部按钮将其撤销。该过程在以下截图中进行说明:

图 2.34:撤销单个覆盖

此外,让我们假设我们真的很喜欢该实例的新缩放比例,因此我们希望所有实例都具有该缩放比例——太好了!我们可以简单地选择特定的覆盖选项,点击应用按钮,然后选择应用全部选项;现在,所有实例都将具有该缩放比例(除了具有覆盖选项的实例),如以下截图所示:

图 2.35:应用按钮

此外,我们还有撤销全部应用全部按钮,但请谨慎使用,因为您很容易撤销和应用您未意识到的更改。

因此,正如您所看到的,预制件是 Unity 中一个非常有用的工具,可以跟踪所有类似对象并对其全部应用更改,它们也有少量变体的特定实例。谈到变体,还有其他情况下您可能希望有具有相同变体集的多个预制件实例——例如,飞行敌人和地面敌人,但如果您考虑一下,我们就会遇到我们未使用预制件时遇到的问题,因此我们需要手动逐个更新这些变体版本。

在这里,我们有两种选择:一种是为具有该变体的另一个版本创建一个新的预制件。这会导致问题,如果我们想让所有类型的敌人都经历变化,我们需要手动将更改应用到每个可能的预制件上。第二种选择是创建一个预制件变体。让我们回顾一下后者。

预制件变体

预制件变体是基于现有预制件创建的新预制件,因此新预制件继承了基础预制件的功能。这意味着我们的新预制件可以与基础预制件有所不同,但它们共有的功能仍然相连。

为了说明这一点,让我们创建一个可以飞行的敌人预制件变体:飞行敌人预制件。为了做到这一点,我们可以在层次结构窗口中选择现有的enemy预制件实例,将其命名为Flying Enemy,并将其再次拖动到项目窗口中,这次我们将看到一个提示,询问我们想要创建哪种类型的预制件。这次,我们需要选择预制件变体,如以下截图所示:

图 2.36:创建预制件变体

现在,我们可以通过双击项目面板中创建的新预制件文件进入变体的预制件编辑模式。然后,添加一个立方体作为敌人的喷气背包,并取消勾选敌人的使用重力属性。如果我们返回到场景,我们将看到变体实例已更改,而基础敌人没有变化。你可以在下面的屏幕截图中看到这一点:

包含设计、艺术的图片,描述自动生成,中等置信度

图 2.37:预制件变体实例

现在,假设你想要给所有类型的敌人添加一顶帽子。我们可以通过双击基础敌人预制件进入预制件编辑模式,然后添加一个立方体作为帽子。现在,我们将看到这个变化应用到所有敌人身上,因为记住:飞行敌人预制件是基础敌人预制件的一个变体,这意味着它将继承所有这些变化。

此外,还有嵌套预制件的概念,它允许你在预制件内部使用预制件,从而巧妙地重复使用预制件的部件。更多信息,请参阅此处 Unity 文档:docs.unity3d.com/Manual/NestedPrefabs.html

我们已经创建了很多内容,但如果我们的电脑因某种原因关闭,我们肯定会丢失所有内容,所以让我们看看我们如何保存我们的进度。

保存场景和项目

就像任何其他程序一样,我们需要保存我们的进度。这里的区别在于,我们不仅仅有一个包含所有项目资产的巨大文件,而且每个资产都有几个文件。

让我们开始保存我们的进度,先保存场景,这很简单。我们可以直接去文件 | 保存或按Ctrl + S(在 Mac 上为Command + S)。第一次保存场景时,会弹出一个窗口询问我们想要将文件保存到何处,你可以在项目Assets文件夹内的任何位置保存它,但永远不要在文件夹外保存;否则,Unity 将无法将其作为项目中的资产找到。这将在项目窗口中生成一个新的资产:场景文件。

在下面的屏幕截图中,你可以看到我是如何保存场景的,将其命名为test,现在它显示在项目面板中:

图片

图 2.38:场景文件

我们可以在保存对话框中创建一个文件夹来保存我们的场景,或者,如果你已经保存了场景,你可以在项目窗口中使用加号+)图标创建一个文件夹,然后点击文件夹选项。最后,将创建的场景拖到该文件夹中。现在,如果你使用文件 | 新建场景菜单选项创建另一个场景,只需双击项目窗口中的场景资产即可返回上一个场景。试试看!

这只保存了场景,但 Prefabs 和其他类型的资产的变化并没有使用该选项保存。相反,如果你想保存除了场景之外的所有资产更改,你可以使用 文件 | 保存项目 选项。这可能会有些令人困惑,但如果你想保存所有更改,你需要同时保存场景和项目,因为只保存项目不会保存场景中的更改。

有时候,确保一切保存的最佳方式就是关闭 Unity,当你尝试在不同计算机或文件夹之间移动项目时,这是推荐的。这会显示一个提示,让你保存场景中的更改,并且会自动保存对其他资产(如 Prefabs)所做的任何更改。

摘要

在本章中,我们快速介绍了必要的 Unity 概念。我们回顾了基本的 Unity 窗口,以及我们如何使用它们来编辑一个完整的场景,从导航它和创建预制对象(Prefabs)到操纵它们以创建我们自己的对象类型,使用 GameObjects 和组件。我们还讨论了如何使用 层次结构 窗口将 GameObjects 设置为父对象以创建复杂对象层次结构,以及如何创建 Prefabs 以重复利用和操作大量相同类型的对象。最后,我们讨论了如何保存我们的进度。

在下一章中,我们将学习关于不同工具的知识,如地形系统和 ProBuilder,以创建我们游戏级别的第一个原型。这个原型将作为我们场景将走向的预览,在进入全面生产之前测试一些想法。

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接以加入社区:

packt.link/unitydev

第三章:从蓝图到现实:使用地形和 ProBuilder 进行建造

现在我们已经掌握了使用 Unity 所需的所有必要概念,让我们开始设计游戏的第一级。本章的目的是学习如何使用地形工具来创建游戏景观,然后使用 ProBuilder 以比使用立方体更详细的方式创建基础 3D 网格。到本章结束时,你将能够创建任何类型的场景原型,并在实际使用最终图形实现之前尝试你的想法。

具体来说,在本章中,我们将探讨以下概念:

  • 定义我们的游戏概念

  • 使用地形工具创建景观

  • 使用 ProBuilder 创建形状

让我们先谈谈我们的游戏概念,这将帮助我们草拟第一级环境。

定义我们的游戏概念

在向场景添加第一个立方体之前,有一个想法是好的,因为我们需要理解游戏的基本概念才能开始设计第一级。在本书中,我们将创建一个射击游戏,玩家将对抗试图摧毁玩家基地的敌人波次。

这个基地将位于一个(不那么)秘密的位置,周围环绕着山脉:

图 3.1:我们的完成游戏

图 3.1:我们的完成游戏

我们将在阅读本书的过程中定义游戏机制,但有了这个游戏的基本高级概念,我们可以开始思考如何创建一个山丘景观和一个占位符玩家基地。

考虑到这一点,在本章的下一节中,我们将学习如何使用 Unity 的地形工具来创建场景的景观。

使用地形创建景观

到目前为止,我们使用立方体来生成我们的级别原型,但我们还了解到立方体有时无法代表我们可能需要的所有可能的物体。想象一下不规则的东西,比如一个完整的带有山丘、峡谷和河流的地形。考虑到地形中的不规则形状,使用立方体来创建这将是一场噩梦。

另一个选择是使用 3D 建模软件,但问题是生成的模型会非常大且非常详细,以至于即使在高端 PC 上表现也不会很好。在这种情况下,我们需要学习如何使用 Unity 的地形系统,我们将在本章的第一节中这样做。

在本节中,我们将介绍与地形相关的以下概念:

  • 讨论高度图

  • 创建和配置高度图

  • 创建高度图

  • 添加高度图细节

让我们先谈谈高度图,其纹理帮助我们定义地形的海拔。

纹理是一种应用于 3D 模型不同部分的图像,以赋予它们细节。这个概念类似于儿童蛋玩具中的贴纸纸,你可以在玩具的不同部分粘贴它们,以给玩具添加眼睛或微笑。我们将在第四章无缝集成:导入和整合资源中更多地讨论纹理。

讨论高度图

如果我们使用常规的 3D 建模工具创建一个包含山丘、峡谷、陨石坑、山谷和河流的大型游戏区域,我们将遇到一个问题,那就是我们将为所有可能距离的对象使用完全详细的模型,从而浪费了渲染我们看不到的细节的资源。在游戏中的几个实例中,玩家需要从相当远的距离查看地形的不同部分,这使得有效的资源管理成为一个严重的问题。Unity 地形工具使用一种称为高度图的技术,以高效和动态的方式生成地形。它不是为整个地形生成大型 3D 模型,而是使用一个称为高度图的图像,它看起来像地形顶部的黑白照片。

在以下图像中,你可以看到苏格兰一个地区的黑白俯视图,白色表示较高,黑色表示较低:

Image result for height map

图 3.2:苏格兰的高度图

在前面的图像中,你可以通过寻找图像中最白的区域来轻松地找到山脉的顶峰。海平面以下的一切都是黑色,而中间的部分使用灰度的渐变,表示最小和最大高度之间的不同高度。其想法是图像中的每个像素都决定了该特定地形区域的高度。

Unity 地形工具可以自动从该图像生成 3D 网格,节省了我们存储该地形完整 3D 模型所需的硬盘空间。此外,Unity 将随着我们的移动创建地形,为附近区域生成高细节模型,为远距离区域生成低细节模型,使其成为一个性能良好的解决方案。

在以下图像中,你可以看到为地形生成的网格。你可以欣赏到地形较近的部分比较远的部分有更多的多边形:

图 3.3:生成的高度图网格

考虑到这项技术有其缺点,例如 Unity 在游戏过程中生成那些 3D 模型所需的时间以及无法创建洞穴。然而,目前这对我们来说还不是问题。

现在我们已经知道了什么是高度图,让我们看看我们如何可以使用 Unity 地形工具创建我们自己的高度图。

创建和配置高度图

如果你点击GameObject | 3D Object | Terrain,你将在场景中看到一个巨大的平面出现,并在你的Hierarchy窗口中看到一个Terrain对象。那就是我们的地形,它很平坦,因为其高度图开始时全部是黑色,所以在初始状态下没有任何高度。

在以下图像中,您可以看到全新的地形对象的外观:

图片

图 3.4:尚未绘制高度的地形

在开始编辑此地形之前,您必须配置不同的设置,例如地形高度图的大小和分辨率,这取决于您打算如何使用它。这不同于生成整个世界。我们的游戏将包含玩家的基地,他们将进行防御,因此地形将较小。在这种情况下,一个 200 x 200 米大小的区域,周围环绕着山脉,将足够使用。

为了根据这些要求配置我们的地形,我们需要执行以下操作:

  1. 层次结构场景窗口中选择地形

  2. 查看地形的检查器组件,如果它已折叠,则展开它。

  3. 点击山脉和齿轮图标(最右侧的选项)以切换到配置模式。在下面的屏幕截图中,您可以看到该按钮的位置:

图片

图 3.5:地形设置按钮

  1. 查找网格分辨率在地形数据)部分。

  2. 在两个设置中将地形宽度地形长度更改为200。这将表示我们的地形大小将是 200 x 200 米。

  3. 地形高度确定可能的最大高度。我们高度图的白区域将是这个大小。我们可以将其减少到500,仅为了限制我们山脉的最大山峰高度:

图片

图 3.6:地形分辨率设置

  1. 查找纹理分辨率(在地形数据)部分。

  2. 高度图分辨率更改为 257 x 257:

图片

图 3.7:高度图分辨率设置

高度图分辨率是包含地形不同部分高度的高度图图像的大小。在我们的 200 x 200 米地形中使用 257 x 257 的分辨率意味着地形的每平方米将覆盖高度图的一点点多于一像素。每平方米的分辨率越高,您可以在该区域大小中绘制出越多的细节。通常,地形特征较大,所以每平方米超过 1 像素通常是资源浪费。找到您能有的最小分辨率,以便您能够创建所需的细节。

您还希望设置的另一个初始设置是初始地形高度。默认情况下,这是 0,因此您可以从底部开始绘制高度,但这种方式您无法在地形中制作洞穴,因为它已经处于最低点。设置一个小的初始高度允许您绘制河流路径和坑洼,以防您需要它们。

为了做到这一点,请执行以下操作:

  1. 层次结构面板中选择地形

  2. 点击绘制地形按钮(第二个按钮)。

  3. 如果尚未设置,将下拉菜单设置为设置高度

  4. 高度属性设置为50。这将表示我们希望所有地形从50米的高度开始,这样我们就可以制作最大深度为50米的洞穴:

图 3.8:设置高度地形工具位置

  1. 点击全部平整按钮。您将看到所有地形都已提升到我们指定的50米。这使我们剩下 450 米可以上升,基于我们之前指定的最大高度 500 米。

现在我们已经正确配置了高度图,让我们开始编辑它。

创建高度图

请记住,高度图只是高度的一个图像,因此为了编辑它,我们需要在该图像中绘制高度。幸运的是,Unity 提供了工具,允许我们在编辑器中直接编辑地形并直接看到修改后高度的结果。为了做到这一点,我们必须遵循以下步骤:

  1. 层次结构面板中选择地形

  2. 点击绘制地形按钮(第二个按钮,与上一节中的相同)。

  3. 将下拉菜单设置为提升或降低地形

图 3.9:提升或降低地形工具位置

  1. 画笔选择器中选择第二个画笔。这个画笔有模糊的边缘,允许我们创建更柔和的高度。

  2. 画笔大小设置为30,这样我们就可以创建跨越 30 米区域的地面高度。如果您想创建更细微的细节,可以减小这个数字。

  3. 不透明度设置为10以减少每秒绘制的地形高度量,或者点击:

图 3.10:平滑边缘画笔

  1. 现在,如果您在场景视图中移动鼠标,您将看到一个小预览,如果您点击该区域,您将绘制的高度。您可能需要靠近地形以查看细节:

图 3.11:提升地形区域预览

您可以看到的勾选图案允许您看到您正在编辑的物体的实际大小。每个单元格代表一平方米。请记住,有一个参考来查看您正在编辑的物体的实际大小有助于防止您创建过大或过小的地形特征。您还可以放入其他类型的参考,例如一个具有准确尺寸的大立方体,代表一个建筑物,以获得您正在创建的山脉或湖泊的大小概念。请记住,立方体的默认尺寸为1 x 1 x 1米,因此将其缩放为10,10,10将得到一个10 x 10 x 10米的立方体:

  1. 按住鼠标左键,将光标拖动到地形上以开始绘制地形高度。请记住,您可以按Ctrl + Z(在 Mac 上为Command + Z)来撤销任何不希望的改变。

  2. 尝试在我们的区域周围绘制山脉,这将代表我们基地的背景山丘:

图 3.12:在地形边缘绘制的山脉

现在,我们未来基地周围已经有了不错的起始山丘。我们也可以在未来的基地周围画一个护城河。要这样做,请遵循以下步骤:

  1. 在地形中间放置一个50,10,50比例的立方体。这将作为我们要创建的基地的占位符:

图片

图 3.13:基础区域的占位符立方体

  1. 再次选择地形画笔按钮。

  2. 画笔大小减少到10

  3. 按住Shift键,左键点击并拖动鼠标在地面上方绘制基地周围的盆地。这样做会降低地形而不是提升它:

图片

图 3.14:我们占位符基地周围的护城河

现在,我们有一个简单但很好的起始地形,它给我们一个基本的想法,了解我们的基地及其周围将是什么样子。在继续之前,我们将应用一些更细致的细节,使地形看起来更好。在下一节中,我们将讨论如何使用不同的工具模拟地形侵蚀。

在学习 Unity 之前,我使用 DirectX 制作游戏,这是一个低级图形库。虽然这是一个挑战,但我真的很享受学习生成自己的地形系统所需的算法。虽然引擎提供了一个制作游戏的实际方法,但制作自己的工具也可以是更好地理解这些引擎如何工作、学习它们的性能和限制以及如何排序的绝佳方式。

添加高度图细节

在前一节中,我们创建了一个地形的大致轮廓。如果你想让它看起来更逼真,那么你需要开始在这里和那里画很多小细节。通常,这会在关卡设计过程的后期进行,但既然我们现在正在探索地形工具,让我们看看。目前,我们的山脉看起来非常平滑。在现实生活中,它们通常更尖锐,所以让我们改进一下:

  1. 选择地形,并像前几节中那样点击画笔按钮。

  2. 如果下拉菜单中没有设置,请将其设置为提升或降低地形

  3. 选择第五个画笔,如图 3.15 所示。这个画笔形状不规则,因此我们可以在这里和那里画一些噪声。

  4. 画笔大小设置为50,以便我们可以覆盖更大的区域:

图片

图 3.15:用于随机性的云图案画笔

  1. 按住Shift键,在不拖动鼠标的情况下在山丘上做小点击。请记住,将区域放大,因为你无法在远处看到这些细节:

图片

图 3.16:使用上述画笔生成的侵蚀效果

这给我们的山丘增加了一些不规则性。现在,让我们想象我们想在山丘上有一个平坦的区域来放置装饰性的天文台或天线。遵循以下步骤来实现这一点:

  1. 选择地形,画笔工具,并从下拉菜单中选择设置高度

  2. 高度设置为60

  3. 选择全圆画笔(第一个)。

  4. 在小山上画一个区域。您会看到如果地形低于 60 米,它将上升,如果高于 60 米,它将下降:

图 3.17:平坦的小山

  1. 您可以看到边界有一些需要平滑的粗糙角落:

图 3.18:未平滑的地形边缘

  1. 将下拉菜单更改为平滑高度

  2. 选择第二支笔刷,如图 3.19 所示,大小为5,不透明度为10

图 3.19:选择平滑高度笔刷

  1. 点击并拖动我们平坦区域的边界以使其更平滑:

图 3.20:平滑地形边缘

如果您想深入了解地形,有一个地形工具扩展包,它添加了更多工具以更精细的细节来雕刻它。请参阅此文档:docs.unity3d.com/Packages/com.unity.terrain-tools@5.1 以及此视频:www.youtube.com/watch?v=smnLYvF40s4 以获取更多信息。

我们可以在这里那里添加更多细节,但我们可以先这样。下一步是创建玩家的基地,但首先,让我们探索 ProBuilder 以生成我们的几何形状。

使用 ProBuilder 创建形状

到目前为止,我们已经使用立方体和原始形状创建了简单的场景,这对您将要创建的大多数原型来说已经足够了,但有时,游戏中可能会有一些难以用常规立方体建模的复杂区域,或者您可能想在游戏的一些部分添加更深的细节,以便了解玩家将如何体验该区域。

在这种情况下,我们可以使用任何 3D 建模工具来完成这项工作,例如 3D Studio Max、Maya 或 Blender,但它们可能难以学习,而且在这个开发阶段您可能不需要所有这些功能。幸运的是,Unity 有一个简单的 3D 模型创建器叫做 ProBuilder,让我们来探索它。

在本节中,我们将介绍与 ProBuilder 相关的以下概念:

  • 安装 ProBuilder

  • 创建形状

  • 网格操纵

  • 添加细节

ProBuilder 默认不包括在我们的 Unity 项目中,所以让我们先学习如何安装它。

安装 ProBuilder

Unity 是一个功能强大的引擎,但如果我们不使用所有这些工具,将它们添加到我们的项目中可能会使引擎运行得更慢,因此我们需要手动指定我们正在使用哪些 Unity 工具。为此,我们将使用包管理器,这是一个我们可以用来选择我们将需要的 Unity 包的工具。如您所回忆的,我们之前谈到了Packages文件夹。这就是包管理器修改的内容。

为了使用此工具在我们的项目中安装 ProBuilder,我们需要执行以下操作:

  1. 点击窗口 | 包管理器选项:

图 3.21:包管理器选项

  1. 在刚刚打开的窗口中,确保 模式处于 Unity 注册表 模式,通过单击窗口左上角的 Unity 注册表 按钮来实现。与仅显示项目已拥有的包的 项目内 选项不同,Unity 注册表 将显示您可以安装的所有官方 Unity 包:

图 3.22:显示所有包

  1. 等待剩余的包列表填充。请确保您已连接到互联网以下载和安装包。

  2. 查看列表中的 ProBuilder 包并选择它。您还可以使用包列表顶部的搜索框:

    图 3.23:ProBuilder 在包列表中

    我使用的是 ProBuilder 版本 5.2.2,这是撰写本书时的最新版本。虽然您可以使用更新的版本,但使用它的过程可能会有所不同。您可以通过标题左侧的箭头查看旧版本。

  3. 包管理器 右上角单击 安装 按钮:

图 3.24:安装按钮

  1. 等待包安装;这可能需要一段时间。当 导入 弹出窗口完成后,安装 按钮被替换为 移除 标签时,您就可以知道过程已经结束。如果由于某种原因 Unity 冻结或超过 10 分钟,您可以随时重新启动它。

  2. 在 Windows 上转到 编辑 | 首选项(在 Mac 上为 Unity | 首选项)。

  3. 从左侧列表中选择 ProBuilder 选项。

  4. 顶点大小 设置为 2,将 线大小 设置为 1。这将帮助您在编辑 3D 模型的不同部分时更好地可视化我们即将创建的模型:

图 3.25:配置 ProBuilder

顶点大小线大小 的值很大(分别为 2 米和 1 米),因为我们不会编辑模型的细节,而是编辑像墙壁这样的大特征。您可能希望根据您正在编辑的内容稍后修改它。

虽然我们只需要了解 包管理器 的这些内容来安装 ProBuilder,但如果您想了解更多,可以在此处查看其文档:docs.unity3d.com/Manual/upm-ui.html

现在我们已经在项目中安装了 ProBuilder,让我们来使用它!

创建形状

我们将通过创建一个用于地板的平面来开始玩家的基础。我们将通过以下步骤来完成此操作:

  1. 删除我们放置的作为基础占位符的立方体。您可以通过在层次结构中右键单击立方体然后单击 删除 来完成此操作。

  2. 打开 ProBuilder 并转到 工具 | ProBuilder | ProBuilder 窗口

图 3.26:ProBuilder 窗口选项

  1. 在打开的窗口中,单击 新形状 按钮:

计算机截图,描述由低置信度自动生成

图 3.27:新形状选项

  1. 在场景视图右下角出现的创建形状面板中,选择平面图标(第一行第二个图标)。

图片

图 3.28:创建的新形状

  1. 展开形状属性平面设置

  2. 宽度切割高度切割设置为2。我们稍后会需要这些细分。

  3. 点击并拖动到地面上以绘制平面。在您这样做的时候,检查创建形状面板中的大小值如何变化,并将XZ值调整为50

  4. 释放鼠标按钮,查看生成的平面。

  5. 在层次结构中选择新创建的平面对象,并使用变换工具稍微向上拖动。

我们需要将平面向上移动,因为它是在与地形完全相同的高度创建的。这导致了一种称为Z 冲突的效果,其中位于相同位置的像素在争夺确定哪个将被渲染以及哪个不会被渲染。

现在我们已经创建了地板,让我们学习如何操作其顶点来改变其形状。

网格操作

如果您选择平面,您会看到它被细分为 3 x 3 的网格,因为我们设置了宽度和高度切割为2。我们这样做是因为我们将使用外层单元格来创建我们的墙壁,从而将其抬高。想法是修改这些单元格的大小,在创建墙壁之前勾勒出墙壁的长度和宽度。为了做到这一点,我们将执行以下操作:

  1. 在层次结构中选择平面。

  2. 如果 ProBuilder 尚未打开,请打开它,并转到工具 | ProBuilder | ProBuilder 窗口选项。

  3. 在场景视图中出现的四个新按钮中,选择第二个按钮(顶点):

图片

图 3.29:选择顶点工具

  1. 点击选择隐藏选项,直到它显示为开启,如图下所示。这将使选择顶点更容易:

图片

图 3.30:启用选择隐藏

  1. 点击并拖动鼠标以创建一个选择框,选择顶点第二行的四个顶点:

图片

图 3.31:顶点选择

  1. 点击第二个按钮 点击场景视图左上角的 4 箭头按钮以启用移动工具,这将允许我们移动顶点。像变换工具一样,这可以用来移动任何对象,但移动顶点时,这是我们唯一的选项。记得在选择了顶点后执行此操作。您也可以按W键来启用移动工具

图片

图 3.32:移动工具

  1. 移动顶点行以使平面的细分更薄。您可以使用地形上的棋盘图案来获得墙壁大小的概念(记住,每个方格是一平方米):

图片

图 3.33:移动后的顶点

  1. 对每一行的顶点重复步骤 35,直到得到类似大小的墙面轮廓:

图片

图 3.34:移动顶点以减少边缘的单元格宽度

现在我们已经为墙壁创建了轮廓,让我们添加新的面到我们的网格中,以创建它们。为了使用我们创建的细分或 来制作墙壁,我们必须选择并挤出它们。按照以下步骤进行操作:

  1. 选择平面。

  2. 场景视图 中选择 ProBuilder 按钮的第四个按钮:

图片

图 3.35:选择面工具

  1. 在按住 Ctrl (Mac 上的Command*) 的同时,点击墙壁轮廓的每个面:

图片

图 3.36:正在选择的边缘面

  1. ProBuilder 窗口中,寻找 Extrude Faces 按钮右侧的 加号 (+) 图标。它位于窗口的红色部分:

包含文本、字体、截图、编号的图片,描述自动生成

图 3.37:挤出面选项

  1. 在点击 + 按钮后出现的窗口中,将 距离 设置为 5

  2. 在该窗口中点击 挤出面 按钮:

图片

图 3.38:挤出距离选项

  1. 现在,你应该看到墙壁的轮廓刚刚从地面上抬起:

图片

图 3.39:挤出网格边缘

现在,如果你注意观察基础地板和墙壁与地形的接触,会发现有一点间隙。我们可以尝试将基础向下移动,但地板可能会消失,因为它会被地形埋藏。这里我们可以用一个小技巧,即向下推墙壁,而不移动地板,这样墙壁就会埋入地形,而我们的地板会保持一定的距离。

你可以在以下图中看到它将如何看起来:

图片

图 3.40:预期结果的切片

为了做到这一点,我们需要执行以下操作:

  1. 场景视图 中选择第三个 ProBuilder 按钮以启用边缘选择:

图片

图 3.41:选择边缘工具

  1. 在按住 Ctrl (Mac 上的Command*) 的同时,选择墙壁的所有底部边缘。

  2. 如果你选择了不想要的边缘,只需在按住 Ctrl (Mac 上的Command*) 的同时再次点击它们,就可以取消选择它们,同时保持当前的选择:

图片

图 3.42:选择地板边缘

如果你想在 球体 图标中使用 线框 模式,请转到场景视图右上角的 2D 按钮左侧,并从下拉菜单中选择 线框 选项,如图下截图所示。你可以通过选择 着色 来返回正常模式。

图片

图 3.43:启用线框模式

  1. 通过按住 场景 面板左上角的第二个按钮(或键盘上的 W 键)来启用 移动 工具:

图片

图 3.44:对象移动工具

  1. 将边缘向下移动,直到它们完全埋入地形中:

图片

图 3.45:重叠的面

现在我们有了基础网格,我们可以开始使用几个其他的ProBuilder工具来添加细节。

记忆

这部分让我想起了我学习如何使用我的第一个 3D 创作软件 Maya 的时候。我使用的是 Autodesk 收购它之前的版本,所以想象一下它有多老了。我喜欢学习基于实际物体蓝图雕刻实体的技术。我记得在我的第一个模型中创建了一个星门和 F302 Tau’ri 飞船。当我写这篇文章时,我已经完成了整个系列的第三次运行(包括亚特兰蒂斯和宇宙)。

添加细节

让我们开始通过在墙上应用一点斜边和在角落上切一点小口来添加基础细节,这样它们就不会那么尖锐。要做到这一点,请按照以下步骤操作:

  1. 使用边缘选择工具(ProBuilder按钮中的第三个),选择我们模型的顶部边缘:

图片

图 3.46:选择顶部墙面边缘

  1. ProBuilder窗口中,点击斜边按钮右侧的+图标。

  2. 设置距离为0.5

图片

图 3.47:生成斜面的距离

  1. 点击斜边边缘。现在你可以看到我们墙的顶部部分有一点斜边:

图片

图 3.48:斜面过程的成果

  1. 可选地,您也可以使用内部墙壁的底部部分:

图片

图 3.49:斜面应用于地板-墙面边缘

另一个可以添加的细节可能是地面中间的一个坑,作为我们需要避免掉入的陷阱,并让敌人通过 AI 避免它。为了做到这一点,请按照以下步骤操作:

  1. 通过点击第四个ProBuilder场景视图按钮启用选择模式。

  2. 选择地板。

  3. ProBuilder窗口中点击细分面选项。最终地板会被分成四块。

  4. 再次点击该按钮,以得到一个 4 x 4 的网格:

图片

图 3.50:地板细分

  1. 使用选择面工具(Scene View顶部ProBuilder按钮中的第三个)同时按住Ctrl(Mac 上的Command)选择四个内部地板砖。

  2. 通过点击场景视图左上角的第四个按钮或按键盘上的R键启用缩放工具。确保工具句柄位置设置为中心(而不是枢轴),这样对象就从对象的中心进行缩放。就像移动工具一样,这可以用来缩放任何对象,而不仅仅是顶点:

图片

图 3.51:缩放工具

  1. 使用 Gizmo 中心的灰色立方体缩小中心砖块:

图片

图 3.52:内部单元格正在缩小

  1. ProBuilder窗口中点击拉伸面按钮。

  2. 使用移动工具将拉伸的面向下推。

  3. 右键点击ProBuilder窗口标签并选择关闭标签。我们需要回到地形编辑,而ProBuilder打开将不会让我们舒适地做到这一点:

图片

图 3.53:关闭标签选项

  1. 选择地形并将其降低,以便我们可以看到坑:

B21361_03_54

图 3.54:降低地形以便可见坑

通过这种方式,我们看到了如何使用不同的 ProBuilder 工具,如拉伸和斜边,来创建简单的网格以原型化我们关卡布局。有时,用普通的立方体来做这样的事情并不容易。

摘要

在本章中,我们学习了如何使用高度图和 Unity 地形工具,如 绘制高度设置高度 来创建山丘和河流,以创建大型地形网格。我们还看到了如何使用 ProBuilder 创建我们自己的 3D 网格,以及如何操纵模型的顶点、边和面来创建我们游戏的原型基础模型。我们没有讨论我们可以应用于我们的网格或高级 3D 建模概念的任何性能优化,因为这需要完整的章节,并且超出了本书的范围。目前,我们的主要重点是原型设计,所以我们对我们关卡当前的状态感到满意。

在下一章中,我们将学习如何通过整合我们使用外部工具创建的资产(文件)来下载和替换这些原型模型,最终艺术作品。这是提高我们游戏图形质量的第一步,我们将在 第三部分提高图形 的结尾完成。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第四章:无缝集成:导入和集成资源

在上一章中,我们创建了我们级别的原型。现在,让我们假设我们已经编写了游戏并进行了测试,确认游戏想法很有趣。有了这个,现在是时候用真正的完成的艺术来替换原型艺术了。我们将在下一章,第五章释放 C#和视觉脚本的力量中实际编写游戏,但为了学习目的,让我们现在先跳过这部分。为了使用最终资源,我们需要学习如何获取它们(图像、3D 模型等),如何将它们导入 Unity,以及如何将它们集成到我们的场景中。

在本章中,我们将探讨以下主题:

  • 导入资源

  • 集成资源

  • 配置资源

让我们先学习如何在 Unity 中获取资源,例如 3D 模型和纹理。

导入资源

我们可以在项目中使用不同来源的资源。我们可以简单地从我们的艺术家那里接收文件,从不同的免费和付费资源网站上下载,或者使用资源商店,这是 Unity 的官方资源虚拟商店,在那里我们可以获取免费和付费的资源,这些资源可以直接用于 Unity。我们将使用从互联网和资源商店下载资源的混合方式,以便使用所有可能资源。

在本节中,我们将介绍与导入资源相关的以下概念:

  • 从互联网导入资源

  • 从资源商店导入资源

  • 从 Unity 包中导入资源

让我们先探索第一个资源来源,互联网。

从互联网导入资源

在获取项目中的艺术资源方面,让我们从我们的地形纹理开始。记住,我们的地形被涂上了网格图案,所以想法是用草地、泥、岩石和其他类型的纹理来替换它。为了做到这一点,我们需要图像。在这种情况下,这类图像通常是不同地形图案的俯视图,并且它们有“可拼接”的要求,这意味着你可以重复它们,而不会在它们的连接处出现明显的图案。你可以在以下图像中看到这个例子:

一张绿色田野的特写  描述由低置信度自动生成

图 4.1:左 - 草地;右 - 分割后的同一草地,以突出纹理拼接

左边的草地看起来像是一张单独的大图像,但如果你仔细观察,你应该能够看到一些图案在重复。在这种情况下,这种草地只是单个图像在网格中重复了四次,就像右边的那样。这样,你可以通过重复单个小图像来覆盖大面积,从而在用户的计算机上节省大量的 RAM。

策略是获取这些类型的图像来绘制我们的地形。你可以从几个地方获取它们,但最简单的方法是使用Google 图片或任何图像搜索引擎。在使用这些来源的任何内容之前,请始终检查版权许可。在搜索纹理时使用关键词“PATTERN 无缝平铺纹理”,用你正在寻找的地形类型替换“PATTERN”,例如“草地无缝平铺纹理”或“泥土无缝平铺纹理”。在这种情况下,我输入了“草地无缝平铺纹理”。一旦你下载了图像,你可以通过几种方式将其添加到你的项目中。最简单的一种方法是执行以下操作:

  1. 使用文件资源管理器(Mac 上的 Finder)定位你的图像。

  2. 在 Unity 的项目窗口中定位或创建纹理文件夹。

  3. 将文件资源管理器和 Unity 项目窗口并排放置。

  4. 将文件从文件资源管理器拖到 Unity 项目窗口中的纹理文件夹:

计算机屏幕截图  描述自动生成,置信度中等

图 4.2:从 Windows 文件资源管理器拖动纹理到 Unity 的项目视图

对于像前图中的简单纹理,任何搜索引擎都可以有所帮助,但如果你想要用详细的墙壁和门替换玩家的基础几何形状,或者在场景中放置敌人,你需要获取 3D 模型。如果你在搜索引擎中使用诸如“免费僵尸 3D 模型”等关键词搜索,你会找到无数免费和付费的 3D 模型网站,如 TurboSquid 和 Mixamo,但那些网站可能会有问题,因为那些网格通常没有为在 Unity 中使用或甚至用于游戏而准备。你会发现具有非常高的多边形计数、不正确的尺寸或方向、未优化的纹理等问题的模型。为了避免这些问题,我们希望使用更好的来源,在这种情况下,我们将使用 Unity 的资产商店,让我们来探索它。

从资产商店导入资产

资产商店是 Unity 的官方资产市场,在这里你可以找到大量的模型、纹理、声音,甚至完整的 Unity 插件来扩展引擎的功能。在这种情况下,我们将限制自己下载 3D 模型以替换玩家的基础原型。你将想要获取具有模块化设计的 3D 模型,这意味着你将得到几块部件,例如墙壁、地板、角落等。你可以将它们连接起来创建任何类型的场景。

为了做到这一点,你必须遵循以下步骤:

  1. 在 Unity 中点击窗口 | 资产商店,这将打开你的网络浏览器到网站assetstore.unity.com。在 Unity 的早期版本中,你可以在编辑器内直接看到资产商店,但现在,必须在一个常规网络浏览器中打开它,所以点击在线搜索按钮,这将在你首选的浏览器中打开网站assetstore.unity.com/。此外,你可以检查从菜单始终在浏览器中打开,以便在点击窗口 | 资产商店时直接打开页面:

包含文本、截图、字体的图片,自动生成描述

图 4.3:资产商店移动消息

  1. 在顶部菜单中,点击3D类别来浏览 3D 资产:

包含文本、截图、字体的图片,自动生成描述

图 4.4:3D 资产菜单

  1. 在最近打开的页面上,点击右侧所有类别面板中3D类别右侧的箭头,然后打开环境并勾选科幻框,因为我们将会制作一个未来主题的游戏:包含文本、截图、字体、数字的图片,自动生成描述

    图 4.5:3D 资产菜单

    如你所见,有几个类别用于查找不同类型的资产,如果你想的话,可以挑选另一个。在环境类别中,你可以找到可用于为你的游戏生成场景的 3D 模型。

  2. 如果需要,你可以为资产付费,但现在让我们先隐藏付费的资产。你可以通过在右侧的定价下拉菜单中勾选免费资产复选框来实现:

图 4.6:免费资产选项

  1. 在搜索区域,找到任何看起来具有你想要的审美的资产并点击它。记得要留意户外资产,因为大多数环境包通常只包含室内场景。在我的例子中,我选择了一个名为科幻风格模块化包的资产,它既包含室内也包含室外。请注意,当你阅读这篇文档时,这个包可能已经不存在了,所以你可能需要选择另一个。如果你找不到合适的包,你可以从 GitHub 仓库github.com/PacktPublishing/Hands-On-Unity-2023-Game-Development-Fourth-Edition下载并选择我们使用的资产文件。

视频游戏的截图,自动生成描述

图 4.7:资产商店搜索包的预览

  1. 现在,你将在资产商店窗口中看到包的详细信息。在这里,你可以找到有关包的描述、视频/图片、包的内容,以及最重要的部分——评论,你可以看到这个包是否值得购买:

视频游戏截图,描述自动生成,置信度中等

图 4.8:资产商店包详细信息

  1. 如果您对这个包没有异议,请点击添加到我的资产按钮,如果需要,请登录 Unity,然后点击在 Unity 中打开按钮。您可能会被提示接受浏览器打开 Unity;如果是这样,只需接受即可:

手机截图,描述自动生成,置信度低

图 4.9:切换应用

  1. 这将再次打开包管理器,但这次是在我的资产模式下,您应该能看到您从资产商店下载的所有资产的列表,以及您刚刚在列表中选择的资产:

图 4.10:包管理器显示资源

  1. 点击窗口右下角的下载,等待其完成。然后点击导入

  2. 一段时间后,包内容窗口将显示出来,允许您选择项目中需要哪些包资源。现在,保持原样并点击导入

计算机截图,描述自动生成,置信度中等

图 4.11:导入资源选择

在导入一段时间后,您将在项目窗口中看到所有包文件。

拥有像资产商店这样的工具确实非常有帮助。当我在其他引擎或不太知名的游戏开发框架上工作时,获取游戏内容是一个挑战。有大量的页面可以获取 3D 模型和纹理,但它们并不总是针对视频游戏进行优化,甚至可能不与 Unity 兼容。当然,如果资产商店没有您需要的资源,知道如何处理也是一个很好的技能,所以我建议您也探索其他可能的资产来源,并看看您将面临哪些挑战。

请注意,导入大量完整包会增加您项目的大小,并且之后您可能希望删除未使用的资产。此外,如果您导入的资产生成了阻止您播放场景的错误,只需删除包中附带的所有.cs文件。它们通常在名为Scripts的文件夹中。这些是可能与您的 Unity 版本不兼容的代码文件:

图 4.12:播放时出现的代码错误警告

在继续本章之前,尝试使用资产商店下载一个角色 3D 模型,按照之前的步骤操作。为了做到这一点,您必须完成与我们之前使用的关卡环境包相同的步骤,但在资产商店的3D | 角色 | 人形类别中查找。在我的情况下,我选择了机器人英雄:PBR HP Polyart包:

视频游戏截图,描述自动生成

图 4.13:我们游戏中使用的角色包

现在,让我们探索 Unity 资产的另一个来源:Unity 包

从 Unity 包中导入资产

资产商店并非资产包的唯一来源;您可以从互联网上获取.unitypackage文件,或者可能从想要与您共享资产的同事那里获取。

如果你想创建自己的资产包以便与其他开发者共享资产,请查看docs.unity3d.com/Manual/AssetPackagesCreate.html上的文档。

为了导入.unitypackage文件,您需要执行以下操作:

  1. 前往资产 | 导入包 | 自定义包...选项:

图 4.14:导入自定义包

  1. 在显示的对话框中搜索.unitypackage文件。

  2. 在出现的导入 Unity 包窗口中点击导入选项——我们在从资产商店导入资产部分看到的那个。

现在我们已经导入了很多艺术资产,让我们学习如何在场景中使用它们。

集成资产

我们刚刚导入了很多可以以多种方式使用的文件,所以本节的想法是看看 Unity 如何将这些资产与需要它们的 GameObject 和组件集成。

在本节中,我们将介绍与导入资产相关的以下概念:

  • 集成地形纹理

  • 集成网格

  • 集成材质

让我们先使用可平铺的纹理来覆盖地形。

集成地形纹理

为了将纹理应用到我们的地形上,请执行以下操作:

  1. 选择地形对象。

  2. 检查器中,点击地形组件的画笔图标(第二个按钮)。

  3. 从下拉菜单中选择绘制纹理

计算机屏幕截图  描述由低置信度自动生成

图 4.15:地形绘制纹理选项

  1. 点击编辑地形层… | 创建层选项。

  2. 在出现的纹理选择器窗口中找到并双击之前下载的地形纹理:计算机屏幕截图  描述由中等置信度自动生成

    图 4.16:纹理选择器窗口

    您将看到纹理将立即应用到整个地形的上。

  3. 重复步骤 4步骤 5以添加其他纹理。这次,您将看到该纹理不会立即应用。

  4. 地形层部分,选择您创建的新纹理以开始使用它进行绘制。我使用的是泥地纹理。

  5. 就像编辑地形时一样,在画笔部分,您可以选择并配置一个画笔来绘制地形。

  6. 场景视图中,绘制你想应用该纹理的区域。

  7. 如果你的纹理图案过于明显,请打开新层 N部分,位于笔刷部分的顶部,其中N是一个取决于你创建的层的数字。每次你将纹理添加到地形中,你都会在项目视图中看到创建了一个名为新层 N的新资产。它包含有关你创建的地形层的数据,如果你需要,可以在其他地形中使用它。你还可以重命名该资产以赋予它一个有意义的名称,或者为了组织目的将这些资产重新组织到它们自己的文件夹中。

  8. 使用左侧的三角形打开该部分,并在平铺设置部分中增加大小属性,直到找到一个图案不明显合适的尺寸:

图 4.17:绘制纹理选项

  1. 重复步骤411,直到你将想要添加到地形中的所有纹理都应用完毕。在我的例子中,我已经将泥地纹理应用到河盆地,并为山丘使用了岩石纹理。对于岩石纹理,我降低了笔刷的不透明度属性,以便更好地与山脉中的草地混合。你可以尝试在顶部添加一层雪,只为增添乐趣:

包含草地、天空、景观、户外场景的图片,描述自动生成

图 4.18:使用三种不同纹理绘制地形的结果

当然,我们可以使用系统的几个高级工具显著提高这一点,但现在让我们保持简单。接下来,让我们看看我们如何将 3D 模型集成到我们的游戏中。

集成网格

如果你选择我们之前下载的 3D 资产之一并点击其右侧的箭头,项目窗口中会出现一个或多个子资产。这意味着我们从资产商店(FBX 文件)下载的 3D 模型文件是包含定义 3D 模型的资产的容器:

视频游戏的截图,描述自动生成

图 4.19:网格选择器

其中一些子资产是网格,它们是一系列定义你的模型几何形状的三角形。你可以在文件中至少找到一个这样的网格子资产,但你也可以找到几个,这可能会发生如果你的模型由很多部分组成。例如,一辆车可以是一个单一的刚性网格,但这不会让你旋转它的车轮或打开它的车门;它将只是一个静态的车,如果车只是场景中的一个道具,这可能就足够了,但如果玩家将能够控制它,你可能需要对其进行修改。想法是,你的汽车的所有部分都是不同的 GameObject,彼此作为父对象,这样如果你移动一个,所有这些都会移动,但你仍然可以独立旋转它的部分。

当你将 3D 模型文件拖到场景中(不是子资产)时,Unity 将自动为每一部分及其适当的父级创建所有对象。你可以在层次结构中选择对象并探索其所有子对象以查看这一点:

图 4.20:子对象选择

此外,你会发现每个对象可能都有自己的Mesh FilterMesh Renderer组件,每个组件仅渲染模型的那一部分。记住,Mesh Filter是一个具有对要渲染的网格资产的引用的组件,因此Mesh Filter是使用我们之前讨论过的那些网格子资产的那个组件。在动画角色的案例中,你会找到Skinned Mesh Renderer组件,但我们将稍后在第三部分提升视觉效果、效果和音频中讨论该组件。

现在,当你将 3D 模型文件拖入场景时,你会得到一个类似于模型是预制件并且你在实例化的结果,但 3D 模型文件比预制件更有限,因为你不能对模型应用更改。如果你已经将对象拖到场景中并编辑它以具有你想要的行为,我建议你创建一个预制件以获得我们在第二章构建场景和游戏元素中讨论的所有好处,例如将更改应用于预制件的全部实例等等。永远不要从模型文件创建大量模型的实例——始终从基于该文件创建的预制件创建它们,以便你可以向其添加额外的行为。

这是 3D 网格的基本用法。现在,让我们探索纹理集成过程,这将使我们的 3D 模型更加详细。

集成纹理

可能你的模型已经应用了纹理,但整个模型都应用了洋红色。如果是这种情况,这意味着资产没有准备好与你在创建项目时选择的通用渲染管线URP)模板一起工作。

资产商店中的一些资产是由第三方编辑器创建的,可能旨在用于 Unity 的旧版本:

白色表面上的一架粉色梯子  描述由中等置信度自动生成

图 4.21:使用错误材质或根本没有材质渲染的网格

修复洋红色资产的一个选项是使用渲染管线转换器,这是一个工具,它将找到它们并将它们(如果可能)重新配置以与 URP 一起工作。为此,每次导入看起来像洋红色的资产时,都要执行以下步骤:

  1. 前往窗口 | 渲染 | 渲染管线转换器

  2. 从下拉菜单中选择内置到 URP选项:

手机的截图  描述由低置信度自动生成

图 4.22:将旧资产升级到 URP

  1. 滚动直到你看到材质升级复选框并勾选它。

  2. 在左下角点击初始化转换器按钮。这将显示所有需要升级的材料列表。我们稍后会更多地讨论材料:

计算机屏幕截图  描述由低置信度自动生成

图 4.23:修复材料以与 URP 一起工作

  1. 点击转换资产按钮,查看模型是否已修复。

您需要关闭窗口以便检测之前打开时未存在的新的洋红色资产。这种方法的一个缺点是,有时它不会正确升级材料。幸运的是,我们可以通过手动重新应用物体的纹理来修复这个问题。即使您的资产工作得很好,我也建议您无论如何都重新应用您的纹理,这样您可以更多地了解材料的概念。

纹理不是直接应用到物体上的。这是因为纹理只是控制模型外观的所有配置中的一个。为了改变模型的外观,您必须创建一个材料。材料是一个独立的资产,其中包含大量关于 Unity 如何渲染您的对象的设置。您可以将该资产应用到具有相同图形设置的多个对象上,如果您更改材料的设置,它将影响所有使用它的对象。它就像一个图形配置文件。

为了创建一个应用物体纹理的材料,您需要遵循以下步骤:

  1. 项目窗口中,点击窗口左上角的加号(+)按钮。

  2. 在该菜单中点击材料选项。

  3. 为您的材料命名。这通常是我们要应用材料的资产名称(例如,CarShipCharacter等等)。

  4. 将创建的材料拖动到场景中的模型实例上。如果您在拖动资产的同时移动鼠标,您将能够看到该材料应用后的预览,对于新材料来说将是白色。我们将在以下步骤中更改这一点。

  5. 通过释放鼠标来应用材料。

  6. 如果您的物体有多个部分,您需要将材料拖动到每个部分。拖动材料将改变您拖动的物体的MeshRenderer组件的材料属性。

  7. 选择材料并点击基础图属性左侧的圆圈(见图 4.23)。

  8. 纹理选择器中,点击您模型的纹理。仅通过观察可能很难找到纹理。通常,纹理的名称将与模型名称匹配。如果不匹配,您需要尝试不同的纹理,直到找到适合您物体的纹理。此外,您可能会发现几个与您的模型名称相同的纹理。只需选择看起来颜色合适的纹理,而不是那些看起来是黑白或浅蓝色的纹理;我们稍后会使用那些:

计算机截图,描述自动生成,中等置信度

图 4.24:URP 材质的基础图属性

通过这种方式,您已经成功通过材质将纹理应用到对象上。对于使用相同纹理的每个对象,只需拖动相同的材质即可。

材质有一个类似于预制件变体的概念,称为材质变体。它包含创建一个基础材质,然后通过对其进行微小修改来创建其替代版本的想法。有关更多信息,请参阅以下文档:docs.unity3d.com/2022.2/Documentation/Manual/materialvariant-landingpage.html,以及以下博客文章:blog.unity.com/engine-platform/material-variants-the-solution-for-managing-complex-material-libraries

现在我们已经对如何应用模型纹理有了基本的了解,让我们学习如何在将模型散布到场景之前正确配置导入设置。

配置资产

如我们之前提到的,艺术家习惯于在 Unity 之外创建艺术资产,这可能会导致从该工具看到的资产与 Unity 导入的资产之间存在差异。例如,3D Studio Max 可以在厘米、英寸等长度单位下工作,而 Unity 使用米作为单位。我们刚刚下载并使用了大量资产,但跳过了配置步骤以解决这些差异,现在让我们来看看这个问题。

在本节中,我们将介绍与导入资产相关的以下概念:

  • 配置网格

  • 配置纹理

让我们先讨论如何配置 3D 网格。

配置网格

为了更改模型的导入设置,您需要定位您下载的模型文件。包含 3D 模型的文件扩展名有多种,其中最常见的是.fbx文件,但您可能会遇到其他格式,如.obj.3ds.blender.mb等。您可以通过文件的扩展名来识别文件是否为 3D 网格:

计算机截图,描述自动生成,中等置信度

图 4.25:所选资产路径扩展

此外,您还可以单击资产,并在检查器中查看以下截图所示的选项卡:

包含文本、截图、字体的图片,描述自动生成

图 4.26:网格材质设置

现在你已经找到了 3D 网格文件,你可以正确地配置它们。目前,我们唯一需要考虑的是模型的适当比例。艺术家们习惯于使用不同软件和不同设置进行工作;也许一位艺术家使用米作为其度量单位创建了模型,而其他艺术家则使用了英寸、英尺等等。当导入使用不同单位创建的资产时,它们可能会不成比例,这意味着我们可能会得到人类比建筑物更大的结果等等。

最佳解决方案是直接要求艺术家进行修复。如果所有资产都是你公司创作的,或者如果你使用了外部资产,你可以要求艺术家按照你公司的标准进行修复,但你现在可能是一个自学 Unity 的单个开发者。幸运的是,Unity 有一个设置允许你在 Unity 中使用之前重新调整原始资产的比例。为了改变对象的缩放因子,你必须执行以下操作:

  1. 在你的项目窗口中定位 3D 网格。

  2. 将其拖动到场景中。你会看到你的场景中会出现一个对象。

  3. 使用GameObject | 3D Object | Capsule 选项创建一个胶囊。

  4. 将胶囊放在你拖入编辑器的模型旁边。看看这个比例是否合理。想法是胶囊代表一个人类(2 米高),这样你就有了一个比例的参考:

一个机器人的卡通  描述由低置信度自动生成

图 4.27:使用胶囊作为参考来调整比例

  1. 如果模型比预期的大或小,请在项目窗口中再次选择网格(而不是你拖到编辑器中的 GameObject 实例)并你将在检查器中看到一些导入设置。在图片中,我们可以看到模型有一个良好的相对尺寸,但仅为了学习目的,我建议继续进行下一步。

  2. 查找缩放因子属性并修改它,如果你的模型比预期的小,则增加它;如果相反,则减少它:

计算机的截图  描述由中等置信度自动生成

图 4.28:模型网格选项

  1. 点击检查器底部的应用按钮。

  2. 重复步骤 6步骤 7,直到你得到期望的结果。

有许多其他选项可以配置,但现在我们先到此为止。接下来,让我们讨论如何正确配置我们模型的纹理。

配置纹理

再次,这里有许多设置可以配置,但让我们现在先关注纹理大小。想法是使用最适合该纹理大小的尺寸,这取决于许多因素。

需要考虑的第一个因素是对象与相机之间的距离。如果你正在创建第一人称游戏,你可能会遇到很多近距离的对象——足够多,可以证明使用大纹理的合理性。然而,如果你有几个远距离的对象,比如建筑物顶部的广告牌,你永远不会足够近以看到细节,你可以为这些对象使用较小的纹理。

另一个需要考虑的因素是对象的重要性。如果你正在创建赛车游戏,你可能会有很多 3D 模型将在屏幕上显示几秒钟,玩家永远不会关注它们;他们会关注道路和其他车辆。在这种情况下,例如,街道上的垃圾桶可能只有很少的纹理和低多边形模型,用户永远不会注意到(除非他们停下来欣赏风景),但这是可以接受的。

最后,你可以玩一个俯视视角的游戏,它永远不会放大场景,所以在这里,与第一人称游戏中具有大纹理的相同对象将具有更少的纹理细节。在以下图像中,你可以看到较小的飞船可以使用较小的纹理:

包含艺术、卡通、图形设计的图片,描述自动生成

图 4.29:从不同距离看到的相同模型

纹理的理想尺寸是相对的。确定正确尺寸的常用方法是改变尺寸,直到你找到在游戏中从最近的位置看到对象时,可以展示良好质量的可能最小尺寸。这是一个试错方法,你可以做以下操作:

  1. 定位 3D 模型并将其放入场景中。

  2. 场景视图相机放置在可以显示对象在游戏中可能最大尺寸的位置。例如,在一个第一人称射击FPS)游戏中,相机可以几乎紧挨着对象,而在俯视游戏中,它会在对象上方几米处。再次强调,这取决于你的游戏。记住,我们的游戏是第三人称射击游戏。

  3. 在与包一起导入的文件夹中或从你之前创建的材料中找到并选择对象使用的纹理。它们通常具有.png.jpg.tif扩展名。

  4. 在检查器中查看最大尺寸属性并减小它,尝试下一个更小的值。例如,如果纹理是2048,尝试1024

  5. 点击应用并检查场景视图,看看质量是否大幅下降,或者变化是否不明显。你会感到惊讶。

  6. 重复步骤 4步骤 5,直到你得到一个质量较差的结果。一旦你做到了,只需提高之前的分辨率以获得可接受的质量。当然,如果你针对的是 PC 游戏,你可以期望比移动游戏更高的分辨率。

现在你已经导入、集成和配置了你的对象,让我们使用这些资产创建玩家的基地。

组装场景

让我们开始使用我们下载的环境包替换我们的原型基础。为此,您必须执行以下操作:

  1. 在我们之前导入的环境包中,找到包含场景中不同部件所有模型的文件夹,并尝试找到一个角落。您可以在项目窗口中的搜索栏中搜索角落关键词:

图 4.30:网格选择器

  1. 在我的具体情况下,角落的外侧和内侧是分开的模型,所以我需要将它们组合在一起。

  2. 将其放置在原型基础任何角落相同的位置:

图 4.31:在占位符上定位网格以替换

  1. 找到与该角落连接以创建墙壁的正确模型。同样,您可以在项目窗口中尝试搜索墙壁关键词。

  2. 实例化它,并将其定位以便与角落连接。如果它没有完美地匹配,不要担心;您将在必要时返回场景。

    您可以选择一个对象并按V键选择所选对象的顶点。然后您可以拖动它,点击变换工具中部的矩形,并将其指向另一个对象的顶点。这被称为顶点吸附。它允许您将场景中的两个部件精确地连接起来。

    图 4.32:连接两个模块

  3. 重复墙壁,直到到达玩家基地的另一端,并放置另一个角落。您可能会得到比原始原型略大或略小的墙壁,但这没关系:包含复合材料、设计的图片  描述自动生成

    图 4.33:连接的模块链

    您可以在按Ctrl键(在 Mac 上为Command)的同时移动对象,以便将对象的定位吸附到顶点,以便可以轻松地找到墙壁的克隆体。另一个选项是在检查器中手动设置变换组件的位置属性。

  4. 完成剩余的墙壁,并销毁在 ProBuilder 中制作的原型立方体。请记住,这个过程很慢,您需要耐心。

  5. 通过寻找地板砖并在整个表面上重复它们来添加地板:

图 4.34:带有坑洞的地板模块

  1. 使用包中的其他模块化部件添加您想要添加的任何细节。

  2. 将所有这些部件放入一个名为Base的容器对象中。请记住创建一个空对象并将基础部件拖入其中:

计算机截图  描述自动生成,置信度中等

图 4.35:网格子资产

通过这种方式,我们学会了如何通过模块化方法轻松创建场景,利用 Unity 的吸附功能组装不同的部件。经过大量实践,你将逐渐积累关于模块化场景设计的常见陷阱和良好实践的宝贵经验。所有包都考虑到了不同的模块化设计,因此你需要适应它们。

摘要

在本章中,我们学习了如何导入模型和纹理并将它们集成到我们的场景中。我们讨论了如何将纹理应用到地形上,如何用模块化模型替换我们的原型网格,如何将这些纹理应用到模型上,以及如何根据对象的用途适当配置资产,同时考虑到多个标准。

通过这种方式,我们已经完成了本书的第一部分Unity 入门,并讨论了我们在整本书中将会使用的几个基本 Unity 概念。在第二部分精通编程和游戏玩法机制中,我们将开始编写游戏的游戏玩法,如玩家的移动和健康系统。我们将开始学习如何创建自己的组件来为我们的对象添加行为,以及脚本的基本结构。

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything(问我任何问题)环节与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第二部分

掌握编程和游戏玩法机制

了解如何使用 C# 和视觉脚本通过游戏脚本增强您的项目。使用玩家输入进行角色移动和对象碰撞检测,以实现游戏元素如武器和健康系统,建立胜负条件,并开发简单但具有挑战性的 AI,提升您游戏的魅力。

本节包含以下章节:

  • 第五章释放 C# 和视觉脚本的力量

  • 第六章动态运动:实现移动和生成

  • 第七章碰撞与健康:准确检测碰撞

  • 第八章胜利或失败:胜负条件

  • 第九章开始您的 AI 之旅:为您的游戏构建智能敌人

第五章:解放 C#和视觉脚本的力量

Unity 拥有许多内置工具来解决游戏开发中最常见的问题,例如我们之前看到的问题。即使是同一类型的两款游戏,它们也有自己独特的小差异,这使得每款游戏都独一无二,Unity 无法预见这些差异,这就是为什么我们需要脚本。在本章中,我们将介绍两种主要的 Unity 脚本选项:C#和视觉脚本。我们将讨论它们的优缺点以及开始使用它们创建游戏玩法所需的基础知识。从现在开始,我们将看到如何使用这两种选项来实现所有我们的脚本。

在本章中,我们将探讨以下主题:

  • 介绍脚本

  • 创建脚本

  • 使用事件和指令

  • 常见的新手 C#脚本错误

我们将创建自己的 Unity 组件,学习脚本的基本结构以及我们可以执行动作和公开属性以进行配置的方式,无论是使用 C#还是视觉脚本。在本章中,我们不会创建任何实际的游戏代码,只是提供一些示例脚本,为下一章打下基础。让我们先讨论 Unity 的脚本选项。

介绍脚本

通过编码,我们可以以多种方式扩展 Unity 的功能,以实现我们需要的精确行为,所有这些都可以通过一种众所周知的编程语言——C#来实现。然而,除了 C#之外,Unity 还拥有视觉脚本,这是一种通过节点图工具生成代码的方法。

这意味着你可以通过拖动节点来创建脚本,而不是编写代码,节点是代表可以串联的动作的盒子:

图 5.1:视觉脚本图的示例

尽管本质上这两种方法可以达到相同的结果,但我们可以根据不同的需求使用它们。通常,由于通常很大并且对性能非常敏感,游戏的核心逻辑是用 C#编写的。然而,有时使用视觉脚本而不是 C#脚本可以让非程序员团队成员,如艺术家或游戏设计师,在游戏中有更多的自由来编辑小的变化,尤其是在平衡或视觉效果方面。

另一个例子是游戏设计师通过视觉脚本原型设计想法,当想法被批准后,程序员会将这些想法转换为 C#脚本。此外,C#程序员可以为视觉脚本程序员创建节点。

在团队之间混合这些工具的方式差异很大,因此,在下一章中,我们将主要关注 C#,但我们也将看到我们将要创建的脚本的视觉脚本等效版本。这样,你将有机会在方便的时候根据团队结构选择使用其中一种。

现在,让我们继续讨论脚本创建的基础知识。

创建脚本

创建行为的第一步是创建脚本资产;这些文件将包含我们组件行为背后的逻辑。C# 和可视化脚本都有自己的资产类型来实现这一点,所以让我们来探索如何在两个工具中实现这一点。

在本书中需要一些编程知识。然而,在本节的第一部分,我们将讨论一个基本的脚本结构,以确保你在接下来的章节中编码游戏行为时有坚实的基础。即使你对 C# 很熟悉,也不要跳过这一节,因为我们将介绍 Unity 特定的代码结构。

在本节中,我们将检查以下脚本创建概念:

  • 初始设置

  • 创建 C# 脚本

  • 添加字段

  • 创建可视化脚本图

我们将创建我们的第一个脚本,它将用于创建我们的组件,讨论创建所需的工具,并探讨如何将我们的类字段暴露给编辑器。让我们从脚本创建的基本知识开始。

初始设置

通过在 包管理器 中安装 可视化脚本 包,我们可以添加对可视化脚本的支持,就像我们在前面的章节中安装其他包一样,但 Unity 在我们创建项目时会自动为我们完成这项工作,所以我们不需要进行任何进一步的设置。这意味着本节的其余部分将负责设置与 C# 一起工作的工具。

在创建第一个 C# 脚本之前,我们需要考虑 Unity 如何编译代码。在编码时,我们习惯于使用 集成开发环境IDE),这是一个用于创建我们的代码并编译或执行它的程序。在 Unity 中,我们将仅使用 IDE 作为工具,通过着色和自动完成功能轻松创建脚本,因为 Unity 没有自定义代码编辑器(如果你之前从未编码过,这些是初学者的宝贵工具)。脚本将在 Unity 项目内部创建,如果进行任何更改,Unity 将检测并编译它们,因此你不需要在 IDE 中编译。不用担心,即使不在 IDE 中编译和运行代码,也可以使用 IDE 和 Unity 一起进行调试、添加断点和检查变量和结构的数据。

我们可以使用 Visual Studio、Visual Studio Code、Rider 或您想使用的任何 C# IDE,但安装 Unity 时,您可能会看到自动安装 Visual Studio 的选项,这允许您拥有默认的 IDE。这会安装 Visual Studio 的免费版本,所以在这里不用担心许可证问题。如果您电脑上没有 IDE,并且在安装 Unity 时没有选择 Visual Studio 选项,您可以执行以下操作:

  1. 打开 Unity Hub 并转到 安装 部分。

  2. 点击你使用的 Unity 版本右上角的轮按钮,然后点击 添加模块

图 5.2:向 Unity 安装添加模块

  1. 选择表示 Visual Studio 的选项;该选项的描述将根据您使用的 Unity 版本和平台而有所不同。

  2. 点击右下角的 继续 按钮:

图 5.3:安装 Visual Studio

  1. 确认您已接受条款和条件,然后点击 安装

图 5.4:接受条款和条件

  1. 等待操作完成。这可能需要几分钟。可能会有一些与平台和版本相关的 Visual Studio 步骤;如果是这样,只需按照它们进行即可。

如果您有一个首选的 IDE,您可以自己安装它并配置 Unity 以使用它。如果您负担得起或者您是教师或学生(在这些情况下它是免费的),我推荐 Rider。这是一个功能强大的 IDE,拥有许多您会喜欢的 C# 和 Unity 功能;然而,它对于本书并非至关重要。为了设置 Unity 以使用自定义 IDE,请执行以下操作:

  1. 打开项目,并在编辑器的顶部菜单中选择 编辑 | 首选项(在 Mac 上为 Unity | 首选项)。

  2. 从左侧面板中选择 外部工具 菜单。

  3. 从外部脚本编辑器中,选择您首选的 IDE;Unity 将自动检测支持的 IDE:

图 5.5:选择自定义 IDE

  1. 如果您在列表中找不到您的 IDE,您可以使用 浏览… 选项。请注意,通常需要使用此选项的 IDE 并非得到很好的支持——但值得一试。

  2. 选择您的 IDE 后,点击 重新生成项目文件。这将重新编译项目所需的所有文件,这样您就不会遇到某些项目文件不存在的问题。

最后,一些 IDE,如 Visual Studio、Visual Studio Code 和 Rider,都有 Unity 集成工具,您需要在项目中安装这些工具,这是可选的但可能很有用。通常 Unity 会自动安装这些工具,但如果您想确保它们已安装,请按照以下步骤操作:

  1. 打开 包管理器窗口 | 包管理器)。

  2. 下拉菜单设置为 Unity 注册表 模式:

图 5.6:启用 Unity 注册表模式

  1. 在列表中搜索您的 IDE 或使用搜索栏进行筛选。在我的情况下,我使用了 Rider,我可以找到一个名为 JetBrains Rider Editor 的包:

图 5.7:自定义 IDE 编辑器扩展安装——在这个例子中是 Rider

  1. 通过查看包管理器右下角的按钮来检查您的 IDE 集成包是否已安装。如果您看到 安装更新 按钮,请点击它;但如果它显示 已安装,则一切设置就绪。

现在我们已经配置好了 IDE,让我们创建第一个脚本。

创建 C# 脚本

C#是一种面向对象的编程语言。任何想要扩展 Unity 的时候,我们都需要创建自己的类——一个包含我们想要添加到 Unity 中的指令的脚本。如果我们想创建自定义组件,我们需要创建一个继承自MonoBehaviour的类,这是每个自定义组件的基类。

我们可以直接在 Unity 项目中使用编辑器创建 C#脚本文件,并且可以将它们排列在其他assets文件夹旁边的文件夹中。创建脚本的最简单方法是按照以下步骤进行:

  1. 选择您想要添加我们即将创建的组件的任何 GameObject。由于我们只是进行测试,所以选择任何对象。

  2. 点击检查器底部的添加组件按钮,然后在点击添加组件后列表底部的新脚本选项:

计算机屏幕截图  描述由低置信度自动生成

图 5.8:新的脚本选项

  1. 名称字段中输入所需的脚本名称,然后点击创建并添加。在我的情况下,我将称之为MyFirstScript,但为了您将要用于游戏的脚本,请尽量使用描述性的名称,无论长度如何!img/B21361_05_09_PRE_BOOK.png

    图 5.9:命名脚本

    建议您使用 Pascal 大小写来命名脚本。在 Pascal 大小写中,用于玩家射击功能的脚本将被称为PlayerShoot。每个单词的首字母都大写,且不能使用空格。

  2. 您可以在项目视图中检查如何创建与您的脚本同名的新资产。请记住,每个组件都有自己的资产,我建议您将每个组件放入一个Scripts文件夹中:

一个蓝色和白色的矩形,上面有黑色文字  描述由低置信度自动生成

图 5.10:脚本资产

  1. 现在,您也会在检查器窗口中看到您的 GameObject 有一个新的组件,其名称与您的脚本相同。因此,您现在已经创建了自己的第一个component类:

img/B21361_05_11.png

图 5.11:将我们的脚本添加到 GameObject 中

现在我们已经创建了一个component类,请记住,类本身不是组件。它是对组件应该是什么的描述——组件应该如何工作的蓝图。要实际使用组件,我们需要通过基于类创建组件来实例化它。每次我们使用编辑器将组件添加到对象时,Unity 都会为我们实例化它。如果您熟悉面向对象的编程语言,您可能会回忆起当我们使用像 C#这样的编程语言时,我们需要在脚本中使用一个特定的关键字来编码对象的实例化:new。在 Unity 中,没有必要这样做;通常,我们不会使用新的 C#关键字来实例化组件,而是通过使用编辑器或专用函数。

现在,你可以像添加任何其他组件一样,通过使用检查器窗口中的添加组件按钮将你的新空组件添加到其他对象中。然后,你可以在脚本类别中查找该组件或通过名称搜索它:

图片

图 5.12:在脚本类别中添加自定义组件

在这里你需要考虑的是,我们可以将相同的组件添加到多个 GameObject 中。我们不需要为使用该组件的每个 GameObject 创建一个类。我知道这是基本的程序员知识,但请记住,我们在这里试图回顾基础知识。

现在我们有了我们的组件,让我们通过以下步骤探索它的外观并进行类结构回顾:

  1. 项目视图中定位脚本资产,并双击它。请记住,它应该在你之前创建的Scripts文件夹中。

  2. 等待 IDE 打开;这可能需要一段时间。当你看到你的脚本代码及其关键字被正确着色,就像以下图所示,这取决于所需的 IDE。在 Rider 中,它看起来就像图 5.13中所示的那样。在我的情况下,我知道 Rider 已经完成了初始化,因为MonoBehaviour类型和脚本名称被着色成相同的颜色:

图片

图 5.13:在 Rider IDE 中打开的新脚本

  1. 上一张截图中的前三行——以using关键字开始的那些——包括常见的命名空间。命名空间就像代码容器,在这种情况下,是其他人(如 Unity、C# 创建者等)创建的代码。我们将经常使用命名空间来简化我们的任务;它们已经包含了我们将要使用的已解决的算法。我们将根据需要添加和删除using组件;在我的情况下,Rider 建议前两个using组件是不必要的,因为我没有使用它们内部的任何代码,所以它们被灰色显示。但暂时保留它们,因为你在本书的后续章节中将会用到它们。记住,它们应该始终位于文件的开头:

图片

图 5.14:使用部分

  1. 下一行,以public class开始的行,是我们声明正在创建一个新的类,该类继承自MonoBehaviour,这是每个自定义组件的基类。我们知道这是因为它以: MonoBehaviour结尾。你可以看到代码的其余部分位于该行下面的括号内,这意味着括号内的代码属于组件:

图片

图 5.15:MyFirstScript 类定义继承自 MonoBehaviour

现在我们有了我们的 C# 脚本,让我们添加字段来配置它。

添加字段

在前面的章节中,当我们添加 Rigidbody 或不同类型的碰撞器等组件时,仅仅添加组件是不够的。我们需要正确配置它们以实现所需的确切行为。例如,RigidbodyMass 属性来控制物体的重量,而碰撞器有 Size 属性来控制它们的形状。这样,我们可以为不同的场景重用相同的组件,防止类似组件的重复。使用 Box 碰撞器,我们可以通过改变大小属性来表示一个立方体或矩形盒子。我们的组件也不例外;如果我们有一个移动对象的组件,并且我们想让两个对象以不同的速度移动,我们可以使用具有不同配置的相同组件。

每个配置都是一个 字段变量,我们可以在这里保存参数的值。我们可以以两种方式在编辑器中创建可编辑的类字段:

  • 通过将字段标记为 public,但打破封装原则

  • 通过创建私有字段并使用属性公开它

现在,我们将介绍这两种方法,但如果你对面向对象编程(OOP)的概念不熟悉,例如封装,我建议你使用第一种方法。

假设我们正在创建一个移动脚本。我们将使用第一种方法——即通过添加 public 字段——添加一个可编辑的数字字段来表示速度。我们将按照以下步骤进行:

  1. 通过双击它打开脚本,就像我们之前做的那样。

  2. 在类括号内,但不在它们内部的任何括号中,添加以下代码:

    图 5.16:在我们的组件中创建速度场

    public 关键字指定变量可以在类的作用域之外被查看和编辑。代码中的 float 部分表示变量使用十进制数字类型,而 speed 是我们为字段选择的名字——尽管这可以是任何你想要的名字。你可以使用其他值类型来表示其他类型的数据,例如 bool 来表示复选框或 Booleansstring 来表示文本。

  3. 要应用更改,只需在 IDE 中保存文件(通常通过按 Ctrl + SCommand + S)并返回 Unity。当你这样做时,你会在编辑器的右下角注意到一个小小的加载轮,这表明 Unity 正在编译代码。你必须在轮子停止转动之前不能测试更改:

    图 5.17:加载轮

    记住 Unity 会编译代码;不要在 IDE 中编译。

  4. 编译完成后,你可以在 Inspector 窗口中看到你的组件,并且应该有一个 Speed 变量,允许你设置你想要的速度。当然,现在,变量没有任何作用。Unity 不会通过变量的名字来识别你的意图;我们需要以某种方式设置它以便使用,但我们将稍后进行设置:

    图 5.18:一个公共字段,用于编辑组件稍后将要使用的数据

    如果你没有看到speed变量,请检查本章末尾的常见初学者 C#脚本错误部分,它将提供有关如何调试编译错误的提示。

  5. 尝试将相同的组件添加到其他对象中,并设置不同的速度。这将展示不同 GameObject 中的组件是如何独立的,允许你通过不同的设置来改变它们的一些行为。

  6. 与我们在前面的步骤中使用public关键字来定义检查器中可用的属性不同,我们创建一个private字段,鼓励封装,并使用SerializeField属性将其公开,如图下所示。

图 5.19:在检查器窗口中公开私有属性

如果你不太熟悉封装的 OOP 概念,只需使用第一个方法,即使用public关键字,在检查器中公开变量,这对初学者来说更灵活。如果你创建一个private字段,它将不会被其他脚本访问,因为SerializeField属性只将变量公开给编辑器。记住,Unity 不会允许你使用构造函数,所以设置初始数据和注入依赖的唯一方法是通过序列化的private字段或public字段,并在编辑器中设置它们(或使用依赖注入框架,但这超出了本书的范围)。为了简单起见,我们将在这本书的大多数练习中使用第一种方法。

如果你愿意,尝试创建其他类型的变量,并检查它们在检查器中的外观。尝试将float替换为boolstring,如之前所建议的。记住,并非所有可能的 C#类型都被 Unity 识别;通过这本书,我们将学习到最常见的支持类型。

既然我们已经知道如何通过数据来配置我们的组件,那么让我们使用这些数据来创建一些行为。

即使我们在使用 C#,这仍然是一种非常快的语言,Unity 也有一个名为 IL2CPP 的功能,它自动将我们的脚本转换为优化的 C++代码。查看此文档以获取更多信息:docs.unity3d.com/Manual/IL2CPP.html。然而,IL2CPP 并不总是必要的,因为你在这本书中将要编写的代码仍然足够快。我们不会进行大规模的包含数千个 GameObject 的模拟,但我仍然建议在移动设备上尝试 IL2CPP,那里的性能提升将会非常显著。

现在我们有了我们的 C#脚本,让我们看看如何在视觉脚本中做同样的事情。

创建一个视觉脚本

由于我们需要为 C#脚本创建脚本资产,因此我们需要创建其视觉脚本等价物,即脚本图,并将其附加到我们的 GameObject 上,尽管这次使用不同的方法。在继续之前,值得注意的是,我们的对象必须只有 C#或视觉脚本版本,但不能两者都有,否则行为将被应用两次,每次针对一个版本。

实际上,只需执行你想要尝试的版本所需的步骤,或者如果你想要实验,可以在不同的对象中执行这两个步骤。

让我们通过以下步骤创建一个视觉脚本:

  1. 创建一个新的 GameObject,我们将向其中添加视觉脚本。

  2. 向其添加脚本机组件。此组件将执行我们即将创建的视觉脚本图

图片

图 5.20:添加脚本机组件

  1. 脚本机组件中,点击新建按钮,选择一个文件夹和一个名称以保存视觉脚本图形资产。此资产将包含我们脚本的指令,脚本机组件将执行这些指令:

图片

图 5.21:使用“新建”按钮创建视觉脚本图形资产

  1. 如果出现警告,请点击立即更改选项。这将防止在脚本上的这些更改在游戏运行时影响游戏,因为警告中提到,它可能导致代码不稳定。始终停止游戏,更改代码,然后再次播放。

  2. 点击编辑图形按钮以打开视觉脚本编辑器窗口。您可以将脚本图选项卡拖动到编辑器的任何部分以合并该窗口:

图片

图 5.22:视觉脚本资产编辑器

  1. 将鼠标放在视觉脚本编辑器网格的空白区域,同时按住中间鼠标按钮,移动鼠标以滚动通过图。在 MacBooks 和 Apple Magic Mouses 上,您可以使用两根手指在触控板上进行滚动。

我们所做的是创建一个包含我们脚本代码的视觉图形资产,并通过脚本机组件将其附加到一个 GameObject 上。与 C#脚本不同,我们不能直接附加图形资产;这就是为什么我们需要脚本机来为我们运行组件的原因。

关于字段,我们在 C#脚本中创建的字段包含在脚本本身中,但对于视觉图来说,它们的工作方式略有不同。当我们添加Script Machine组件时,还添加了另一个组件:Variables组件。这将保存 GameObject 可以包含的所有视觉脚本图的变量。这意味着我们添加到对象的所有图都将共享这些变量。如果你想创建特定于图的变量,也可以,但它们不会在检查器中暴露,这种方式也简化了从其他对象的脚本中访问变量的过程。此外,请记住,你可能会想向对象添加多个图,因为每个图将处理不同的行为,这样我们就可以根据需要混合和匹配它们。

为了将一个变量添加到我们的 GameObject 中,使其可以被我们的图使用,让我们做以下操作:

  1. 选择添加了视觉脚本(带有Script Machine组件)的 GameObject,并查看Variables组件。

  2. 点击显示(New Variable Name)的输入字段,并输入变量的名称。在我的例子中,这是speed。如果你看不到这个选项,点击Variables组件名称左侧的三角形。

  3. 点击Variables组件的Plus (+)按钮。

  4. Type下拉菜单中,选择Float

  5. 可选地,你可以在Value字段中设置一个初始值:

计算机屏幕截图,描述由中等置信度自动生成

图 5.23:为视觉图创建变量

我们创建了一个speed变量,我们可以在 GameObject 中配置它来改变所有附加到我们的 GameObject 的视觉脚本图的工作方式,或者至少是使用该变量值的那些图。考虑一下,你可能会有不同类型的速度,比如移动速度和旋转速度,所以在实际情况下,你可能希望变量名称更具体一些。

在视觉脚本中,Variables组件用于管理数据,类似于在某些编程系统中使用黑板来跨不同组件或代理共享和访问数据的方式。这个黑板是我们对象几个值的容器,就像内存或数据库,然后其他几个组件将查询并使用这些值。C#脚本通常在其内部包含自己的变量。随着我们创建的脚本准备就绪并可以配置,让我们看看如何使它们都发挥作用。

使用事件和指令

现在我们有了脚本,我们准备用它做些事情。在本章中,我们不会实现任何有用的功能,但我们将确定基础概念,以便在下一章中创建的脚本中添加有趣的行为。

在本节中,我们将介绍以下概念:

  • C#中的事件和指令

  • 视觉脚本中的事件和指令

  • 在指令中使用字段

我们将要探索Unity 事件系统,这将允许我们通过执行指令来响应不同的情况。这些指令也会受到编辑器值的影响。最后,我们将讨论常见的脚本错误及其解决方法。让我们先从介绍 C#中的 UnityEvents 概念开始。

C#中的事件和指令

Unity 允许我们以因果关系的方式创建行为,这通常被称为事件系统。事件是 Unity 正在监控的情况——例如,当两个对象碰撞或被销毁时,Unity 会告诉我们这种情况,允许我们根据我们的需求做出反应。例如,当玩家与子弹碰撞时,我们可以减少玩家的生命值。在这里,我们将探索如何监听这些事件,并使用一些简单的操作来测试它们。

如果你习惯了事件系统,你会知道它们通常需要我们订阅某种监听器或委托,但在 Unity 中,有一个更简单的方法可用。对于 C#脚本,我们只需要编写一个与我们要使用的事件具有完全相同名称的函数——我的意思是完全相同。如果名称中的任何一个字母的格式不正确,它将不会执行,并且不会发出警告。这是最常见的初学者错误,所以请注意。对于可视化脚本,我们将添加一种特殊的节点,但我们将在此之后讨论这一点。

在 Unity 中有很多事件或消息需要监听,所以让我们从最常见的一个开始——Update。这个事件会告诉你 Unity 何时想要更新你的对象。然而,根据你行为的目的,并不是所有对象或脚本都需要使用Update事件。Update逻辑通常是需要不断执行的事情——更准确地说,是在每一帧执行。记住,每个游戏就像是一部电影——一系列快速切换到屏幕上的图像,看起来我们有了连续的运动。在Update事件中执行的一个常见操作是稍微移动对象,通过这样做,每一帧都会让你的对象持续移动。

我们将在稍后了解我们可以使用Update和其他事件或消息做什么。现在,让我们专注于如何让我们的组件至少监听这个事件。实际上,基础脚本已经包含了两个可以立即使用的函数,一个是Update,另一个是Start。如果你不熟悉 C#中的方法概念,我们指的是以下截图中的代码片段,它已经包含在我们的脚本中。试着在你的脚本中找到它:

图片

图 5.24:一个名为Update的函数,它将在每一帧执行

你会注意到在 void Update() 行上方的(通常是)绿色文本行(取决于 IDE)——这被称为 注释。这些基本上被编译器忽略。它们只是你可以留下的笔记,必须始终以 // 开头,以防止 Unity 尝试执行它们并失败。我们将使用这个功能来暂时禁用代码行。

现在,为了测试 Update 方法是否真正起作用,让我们添加一个始终要执行的指令。没有比 print 更好的测试函数了。这是一个简单的指令,告诉 Unity 将消息打印到控制台,开发者可以在那里看到各种消息,以检查一切是否正常工作。用户永远不会看到这些消息。它们类似于开发者有时在游戏中出现问题时向你请求的经典日志文件。

为了使用函数测试 C# 中的事件,请按照以下步骤操作:

  1. 通过双击脚本来打开它。

  2. 为了测试,在事件函数中添加 print(“test”);。在下面的屏幕截图中,你可以看到一个在 Update 事件中如何做到这一点的示例。请记住,指令必须 完全正确,包括正确的大小写、空格和引号符号:

包含文本、字体、截图、白色描述的图片,描述由系统自动生成

图 5.25:在所有帧中打印消息

  1. 保存文件,进入 Unity,并玩游戏。

    在从 IDE 切换回 Unity 之前,请务必保存文件。这是 Unity 知道你的文件已更改的唯一方式。一些 IDE,如 Rider,会自动为你保存文件,但我不建议你在大型项目中使用自动保存。你不想意外地重新编译未完成的工作——在包含大量脚本的项目中,这会花费很长时间。

  2. 查找 控制台 选项卡并选择它。这通常位于 项目视图 选项卡旁边。如果你找不到它,请转到 窗口 | 通用 | 控制台,或按 Ctrl + Shift + C(在 macOS 上为 Command + Shift + C)。

  3. 你将在 控制台 选项卡上的每一帧中看到一个新打印的消息,内容为 “test”。如果你没有看到这个消息,请记住在玩游戏之前保存脚本文件。

  4. 你可能会看到一个单独的消息,但右侧的数字在增加;这意味着相同的消息出现了多次。尝试点击控制台选项卡的 折叠 按钮来改变这种行为。

  5. 让我们也测试一下 Start 函数。向其中添加 print(“test Start”);,保存文件,并玩游戏。完整的脚本应如下所示:

图 5.26:测试 Start 和 Update 函数的脚本

如果您现在检查控制台并滚动到最上方,您将看到一个单独的 “test Start” 消息和随后的许多 “test” 消息。正如您所猜到的,Start 事件告诉您 GameObject 已创建,并允许您执行在其生命周期开始时只需执行一次的代码。

对于 void Update() 语法,我们将告诉 Unity,此行以下括号内的任何内容都是一个将在所有帧中执行的功能。将 print 指令放在 Update 括号内(类括号内的括号)内非常重要。此外,print 函数期望在其括号内接收一个要打印的值,称为参数或参数。在我们的例子中,我们想要打印简单的文本,在 C# 中,它必须用引号括起来。最后,所有 UpdateStart 等函数中的指令必须以分号结束。

在这里,我挑战您尝试添加另一个名为 OnDestroy 的事件,使用 print 来发现它何时执行。一个小建议是播放并停止游戏,查看控制台底部来测试这个。

对于高级用户,如果你的 IDE 允许,你还可以使用断点。断点允许你在执行特定代码行之前完全冻结 Unity,以查看我们的字段数据随时间的变化并检测错误。在这里,我将向您展示在 Rider 中使用断点的步骤,但 Visual Studio 版本应该类似:

  1. 如果尚未安装,请安装属于您的 IDE 的 Unity 包。检查包管理器中的JetBrains Rider 编辑器包。在 Visual Studio 的情况下,安装Visual Studio 编辑器包。

  2. 点击您想要添加断点的行的左侧垂直栏:

图 5.27:打印指令中的断点

  1. 前往运行 | 连接到 Unity 进程。如果您正在使用 Visual Studio,请前往调试 | 连接到 Unity 调试器

图 5.28:用 Unity 进程攻击我们的 IDE

  1. 从列表中,查找您想要测试的特定 Unity 实例。如果存在其他打开的编辑器或正在运行的调试构建,列表将显示它们。

  2. 如果这不起作用,请检查编辑器是否处于调试模式,通过查看编辑器右下角的错误图标。如果错误图标看起来是蓝色的带有复选框,那么它是正常的,但如果它看起来是灰色的并且被划掉,请点击它并点击切换到调试模式

图 5.29:从发布模式切换到调试模式

停止调试过程不会关闭 Unity。它只会将 IDE 从编辑器中分离出来。请记住,您可以在 Visual Studio(以及其他 IDE 中的等效按钮)中点击继续按钮,继续游戏执行而不分离调试器。

现在,让我们探索使用事件和指令的视觉脚本等效方法。

视觉脚本中的事件和指令

在可视化脚本中,事件和指令的概念保持不变,但当然,这将在图中使用节点来完成。记住,一个节点代表图中的一个指令,我们可以将它们连接起来以链式执行每个指令的效果。为了在我们的图中添加事件和打印指令,请执行以下操作:

  1. 打开可视化脚本图(双击可视化脚本资产)。

  2. 右键单击默认创建的On StartOn Update节点,然后单击删除。即使这些事件是我们需要的,我也想让你看到如何从头创建它们:

图片

图 5.30:删除节点

  1. 在图的任何空白空间中右键单击,并在搜索框中输入start。第一次可能需要一些时间。

  2. 在左侧带有绿色复选框的列表中选择On Start元素。在这种情况下,我知道这是一个事件,因为我了解它,但通常,你会因为它没有输入引脚而将其识别为事件(更多内容将在下一步中介绍):

图片

图 5.31:搜索 On Start 事件节点

  1. 拖动事件节点右侧的白色箭头,也称为输出流引脚,并在任何空白空间中释放鼠标按钮。

  2. 搜索框中搜索print节点,并选择显示为Mono Behaviour:Print的节点。这意味着当On Start事件发生时,连接的节点将被执行——在这种情况下,打印。这就是我们开始将指令链式连接到事件的方式:

图片

图 5.32:创建与事件连接的打印节点

  1. 拖动print节点Message输入引脚左侧的空圆圈,并在任何空白空间中释放它。这个引脚上有一个圆圈标记,表示它是一个参数引脚,包含在执行引脚时将使用的数据。带有绿色箭头的流引脚代表节点执行的顺序。

  2. 选择字符串字面量选项,这将创建一个节点,使我们能够指定要打印的消息:

图片

图 5.33:创建字符串字面量节点

  1. 在空白白色框中,写下要打印的消息:

图片

图 5.34:指定要打印的消息

  1. 玩游戏,并查看消息在控制台中打印出来。确保场景中只有可视化脚本版本,以避免将控制台中的消息与 C#版本混淆。你还可以在可视化脚本中使用不同的消息文本,以确保哪些是真正执行的。

现在我们有了之前用 C#编写的相同行为,但现在使用 Unity 中的可视脚本工具。您可以通过将Print节点的输出流引脚(Output Flow Pin)拖到右边来将更多动作链接到On Start,但我们将稍后进行。现在我们的脚本正在做些事情,让我们让指令使用我们创建的字段,以便脚本使用它们的配置。

在指令中使用字段

我们已经创建了字段来配置组件的行为,但到目前为止我们还没有使用它们。我们将在下一章创建有意义的组件,但我们会经常需要使用我们创建的字段来改变对象的行为。到目前为止,我们还没有真正使用我们创建的speed字段。然而,按照测试代码是否工作(也称为调试)的想法,我们可以学习如何使用函数中的字段数据来测试值是否为预期的,根据字段值在控制台中的print输出进行更改。

在我们当前的 C#脚本中,我们的speed值在运行时不会改变。然而,作为一个例子,如果您正在创建一个带有护盾伤害吸收的生命系统,并且想要测试减少的伤害计算是否正常工作,您可能想要将计算值打印到控制台并检查它们是否正确。

这里的想法是将print函数内部的固定信息替换为一个字段。当您这样做时,print将在控制台中显示字段的值。所以如果您在speed中设置值为5并打印它,您将在控制台中看到许多显示5的消息,print函数的输出由字段控制。为了测试这一点,您在Update函数中的print消息应该如下所示:

包含文本、字体、截图和自动生成的行描述的图片

图 5.35:使用字段作为 print 函数参数

如您所见,我们只是将字段的名称不加引号地放入。如果您使用引号,则会打印出speed信息。在其他场景中,您可以使用这个speed值在某个移动函数中控制移动速度,或者您也许可以创建一个名为“fireRate”的字段来控制子弹之间的冷却时间(字段使用驼峰命名法而不是 Pascal 命名法,首字母小写):

图 5.36:打印当前速度

现在,为了使可视脚本图打印出我们在变量组件中创建的speed变量的值,让我们做以下操作:

  1. 打开可视脚本图资产(双击它)。

  2. 在左侧的面板中,选择对象选项卡以显示对象拥有的所有变量——基本上是我们之前在变量组件中定义的变量。

  3. 速度变量拖动到变量框左侧的两条线上的任何空白区域。这将创建一个表示变量的获取变量节点。

图 5.37:将变量拖动到图中以供节点使用

  1. 获取变量节点右侧的空圆圈拖动到打印节点消息输入引脚左侧的圆圈。这将替换之前连接到字符串文字节点的连接。此节点没有输入输出流节点(绿色箭头节点),因为它们是仅提供数据给其他节点的数据节点。在这种情况下,当打印需要执行时,它将执行获取变量以获取要读取的文本:

图 5.38:将速度变量连接到打印节点

  1. 右键单击字符串文字节点并删除它。

  2. 播放游戏并观察。

所有这些,我们现在有了开始创建实际组件所需的工具。在继续之前,让我们回顾一下你可能会在第一次用 C#编写脚本时遇到的常见错误。

常见的 C#脚本错误

视觉脚本脚本以减少错误的方式准备,不允许你编写像 C#脚本那样的错误语法。如果你是一位经验丰富的程序员,我敢打赌你对它们非常熟悉,但让我们回顾一下当你开始使用 C#脚本时会让你浪费很多时间的常见错误。其中大多数是由于没有完全复制显示的代码造成的。如果你在代码中有错误,Unity 将在控制台中显示红色消息,并且不允许你运行游戏,即使你没有使用脚本。所以永远不要留下未完成的事情。

让我们从经典的错误开始,一个缺少分号,这导致了许多程序员的梗和笑话。所有字段和函数(如print)内部的大多数指令在调用时都需要在末尾有一个分号。如果你不添加分号,Unity 将在控制台中显示错误,如图 5.39中的截图所示。

你还会注意到这也有一个不良代码的例子,其中 IDE 显示红色图标,表明该位置有问题:

图 5.39:由 IDE 和 Unity 控制台标记的打印行错误

你会注意到错误显示了确切的脚本(MyFirstScript.cs)、确切的代码行(在这种情况下是14),以及通常一个描述性的消息——在这种情况下是;expected——作为指定指令在那里结束的方式,以便编译器可以处理下一个指令作为单独的一个。你可以简单地双击错误,Unity 将打开 IDE,突出显示有问题的行。你甚至可以点击堆栈中的链接跳转到你想要的堆栈行。

我已经提到为什么对指令的每个字母使用确切的缩写很重要。然而,根据我教授初学者的经验,我需要更强调这个特定的方面。

这种情况可能发生的第一个场景是在指令中。在下面的屏幕截图中,你可以看到一个写得不好的 print 函数的样子——即控制台将显示的错误以及 IDE 将建议有错误。首先,在 Rider 的情况下,指令被标记为红色,表示指令不被识别(在 Visual Studio 中,它将显示一条红色横线)。然后,错误信息说 Print 在当前上下文中不存在,这意味着 Unity(或实际上 C#)不识别任何名为 Print 的指令。在另一种类型的脚本中,大写的 Print 可能是有效的,但在常规组件中则不是,这就是为什么存在“在当前上下文中”的说明:

图 5.40:编写指令错误时的错误信息

现在,如果你使用错误的缩写编写一个事件,情况会更糟。你可以创建名为 StartUpdate 的函数,为其他目的使用任何你想要的名称。编写 updatestart 是完全有效的,因为 C# 会认为你打算将这些函数用作常规函数,而不是事件。所以,不会显示错误,你的代码将无法正常工作。尝试用 update 代替 Update 看看会发生什么:

包含文本、字体、截图、白色描述自动生成

图 5.41:更新函数中错误的缩写会导致函数编译但不会执行

另一个错误是将指令放在函数括号之外,例如在类的括号内或括号外。这样做将不会给函数任何关于何时需要执行提示。所以,在 Event 函数之外的 print 函数是没有意义的,它将显示类似于 图 5.425.43 中的错误。

这次,错误并不非常描述性。C# 期望你创建一个函数或字段——一种可以直接放在类中的结构:

图 5.42:指令或函数调用位置错误

最后,另一个经典的错误是忘记关闭未闭合的括号。如果你不关闭一个括号,C# 将不知道函数在哪里结束,另一个函数在哪里开始,或者类函数在哪里结束。这听起来可能有些多余,但 C# 需要这一点来定义得完美。在下面的屏幕截图中,你可以看到这会是什么样子:

图 5.43:缺少闭合括号

在寻找行尾缺失的分号上浪费了太多时间。起初,试图找出代码中哪里出错了非常令人沮丧,但与此同时,找到解决方案并让一切恢复正常工作则非常令人满意。这种挫折与满足的循环仍然会发生在我的身上,也许是因为更复杂的问题,但这些都是成为一名开发者的一部分。你越早接受它,你就能越快看到进步。

这个问题有点难以捕捉,因为代码中的错误显示在实际错误之后。这是由于 C#允许你在函数内部放置函数(不常用),因此 C#会在稍后检测到错误,要求你添加一个闭合括号。然而,由于我们不希望将Update放在Start内部,我们需要在Start的末尾事先修复错误。错误信息将在控制台中描述性显示,但同样,除非你 100%确定位置正确,否则不要按照消息建议的位置放置闭合括号。

你可能会遇到很多错误,但它们都是一样的。IDE 会给你一个提示,控制台会显示一条消息;随着时间的推移,你会学会如何解决它们。只需保持耐心,因为每个程序员都会经历这个过程。还有其他类型的错误,例如运行时错误,代码可以编译但在执行时由于某些配置错误而失败,或者最糟糕的是,逻辑错误,你的代码可以编译并执行而不出现错误,但并不执行你想要的功能。

摘要

在本章中,我们探讨了你在创建脚本时将使用的的基本概念。我们讨论了脚本资产的概念以及 C#脚本必须继承自MonoBehaviour才能被 Unity 接受以创建我们自己的脚本。我们还看到了如何混合事件和指令来为对象添加行为,以及如何在指令中使用字段来自定义它们的功能。所有这些都是在使用 C#和可视化脚本的同时完成的。

我们刚刚探讨了脚本的基础知识,以确保每个人都处于同一水平线上。然而,从现在开始,我们将假设你已经在某种编程语言中具备基本的编码经验,并且知道如何使用诸如ifforarray等结构。如果不是这样,你仍然可以阅读这本书,并尝试使用 C#入门书籍来补充你不理解的部分,正如你需要的那样。

在下一章中,我们将开始看到如何使用我们所学到的知识来创建移动和生成脚本。

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

第六章:动态运动:实现移动和生成

在上一章中,我们学习了脚本的基础知识,所以现在,让我们为我们的游戏创建第一个脚本。我们将看到如何通过脚本使用Transform组件来移动物体,这将应用于玩家通过键盘按键的移动、子弹的持续移动以及其他物体的移动。此外,我们还将了解如何在游戏中创建和销毁物体,例如玩家和敌人射击的子弹以及游戏过程中将生成的敌人波(也称为敌人生成器)。这些操作可以应用于多种其他场景,因此我们将在本章中探讨其中的一些。

在本章中,我们将探讨以下脚本概念:

  • 实现移动

  • 实现生成

  • 使用新的输入系统

我们将首先编写脚本组件来使用键盘移动我们的角色,然后我们将让我们的玩家射击子弹。需要考虑的是,我们将首先展示 C#版本,然后在每个部分展示相应的视觉脚本等效版本。

实现移动

几乎游戏中每个物体都会以某种方式移动:玩家角色通过键盘操作,敌人通过人工智能,子弹简单地向前移动,等等。在 Unity 中,有几种移动物体的方法,因此我们将从最简单的一种开始——那就是使用Transform组件。

在本节中,我们将探讨以下运动概念:

  • 通过Transform移动物体

  • 使用输入

  • 理解 Delta Time

首先,我们将探讨如何在脚本中访问Transform组件以驱动玩家移动,稍后根据玩家的键盘输入应用移动。最后,我们将探讨delta time的概念,以确保在每台计算机上移动速度的一致性。我们将开始学习Transform API 以制作一个简单的移动脚本。

通过 Transform 移动物体

Transform是包含物体平移、旋转和缩放的组件,因此每个运动系统,如物理或路径查找,都会影响此组件。有时,我们想要根据我们的游戏以特定方式移动物体,通过创建自己的脚本来实现,该脚本将处理我们需要的移动计算并修改Transform以应用它们。

这里适用的一个概念是组件可以改变其他组件。在 Unity 中编码的主要方式是创建与其他组件交互的组件。在这里,想法是创建一个可以访问另一个组件并告诉它做某事的组件——在这种情况下,就是移动。要创建一个告诉Transform移动的脚本,请执行以下操作:

  1. 创建并添加一个名为 PlayerMovement 的脚本到我们的角色中,就像我们在上一章中所做的那样。在这种情况下,它将是之前下载的动画 3D 模型,命名为 Polyart_Mesh(从 项目 视图中拖动 3D 资产到场景中)。记住在创建后,将脚本移动到 脚本 文件夹:

图 6.1:在角色中创建 PlayerMovement 脚本

  1. 双击创建的脚本资产以打开 IDE 并编辑代码。

  2. 角色正在移动,并且这种移动会应用到每一帧。因此,这个脚本将只使用 Update 函数或方法,我们可以移除 Start(移除未使用的函数是一种良好的实践):

图 6.2:仅包含 Update 事件函数的组件

  1. 要使我们的对象沿着其局部前进轴(即 z 轴)移动,请在 Update 函数中添加 transform.Translate(0,0,1); 这一行,如图 图 6.3 所示:

    每个组件都可以访问一个 Transform 字段(具体来说,是一个 getter),它是对放置该组件的 GameObject 的 Transform 的引用。通过这个字段,我们可以访问 TransformTranslate 函数,该函数将接收应用到 xyz 本地坐标的偏移量。

    图 6.3:一个简单的向前移动脚本

  2. 保存文件,并玩游戏以查看移动效果。确保相机指向角色,以便正确地看到脚本的效果。为此,请记住您可以在层次结构中选择相机,并将其移动和旋转,直到角色位于视锥体内。

现在我们已经为玩家实现了简单的移动,您会注意到他们移动得太快。这是因为我们正在使用固定的速度 1 米,并且因为 Update 在每一帧执行,所以我们每帧移动 1 米。在一个标准的 30 每秒帧数FPS)的游戏中,玩家会移动 30 米每秒,这太多了,但我们的计算机可能以远高于这个 FPS 运行游戏。我们可以通过添加一个 speed 字段并使用编辑器中设置的值而不是固定的 1 米值来控制玩家的速度。您可以在 图 6.4 中看到一种实现方式,但请记住我们在 第五章 中讨论的其他选项,释放 C# 和视觉脚本的力量

图 6.4:创建速度字段并将其用作移动脚本的 z 速度

现在,如果您保存脚本以应用更改并在编辑器中设置玩家的 速度,您就可以玩游戏并看到结果。在我的情况下,我使用了 0.1,但您可能需要另一个值(关于这一点,请参阅 理解 Delta Time 部分):

图 6.5:设置每帧 0.1 米的速度

现在,对于视觉脚本版本,请记住不要混合我们的脚本的 C# 和视觉脚本版本;这并不是因为它不可能,而是因为我们现在想保持事情简单。所以你可以从玩家对象中删除脚本并添加视觉脚本版本,或者你可以创建两个玩家对象并启用和禁用它们来尝试两个版本。我建议为脚本的 C# 版本创建一个项目,然后创建第二个项目来实验视觉脚本版本。

此脚本的视觉脚本图将如下所示:

图 6.6:设置每帧速度为 0.1 米

如你所见,我们在 Player GameObject 中添加了一个 脚本机器组件。然后,我们在 脚本机器组件中按下 新建按钮来创建一个名为 PlayerMovement 的新 。我们还创建了一个名为 speed浮点变量,其值为 0.1。在 中,我们添加了 On Update 事件节点并将其连接到 TransformTranslateX,Y,Z)节点,这与 C# 版本类似,将沿着对象的局部轴移动。最后,我们将 TranslateZ 参数引脚连接到表示我们在 GameObject 中创建的速度的 GetVariable 节点。如果你将此 与我们在 C# 版本中使用的代码进行比较,它们基本上是相同的 Update 方法和 Translate 函数。如果你不记得如何创建此 ,你可以回到 第五章释放 C# 和视觉脚本的力量,来回顾这个过程。

你会注意到玩家会自动移动。现在,让我们看看如何根据 玩家输入(如键盘和鼠标)来执行移动。

使用输入

与电子游戏中的 NPC不可玩角色)不同,我们希望玩家的移动由用户的输入驱动,基于他们在玩游戏时按下的键、鼠标移动等。为了知道是否按下了某个键,例如 向上 方向键,我们可以使用 Input.GetKey(KeyCode.W) 行,这将返回一个布尔值,指示 KeyCode 枚举中指定的键是否被按下,在这种情况下是 W。这通常是 3D 电子游戏中键盘控制器的主要设置。我们可以将 GetKey 函数与一个 If 语句结合使用,以便在按键时执行翻译。

首先,我们按照以下步骤实现键盘移动:

  1. 使用代码使前进移动仅在按下 W 键时执行,如下一个截图所示:

图 6.7:在按下 W 键之前条件化移动

  1. 我们可以使用更多的If语句添加其他移动方向,使用AD键来左右移动,如下面的截图所示。注意我们如何使用负号在需要沿相反轴方向移动时反转速度:

图 6.8:检查 W、S、A 和 D 键的压力

  1. 如果你还想考虑箭头键,你可以在if中使用OR,如下面的截图所示:

图 6.9:检查 W、S、A、D 和箭头键的压力

  1. 保存更改,并在播放模式下测试移动。

通过这些代码行,我们使用WASD键实现了基本的移动。需要注意的是,首先,我们还有另一种方法通过配置输入管理器将多个键映射到单个动作——这是一个可以创建动作映射的地方。其次,在撰写本文时,Unity 发布了一个比这个更可扩展的新输入系统。

目前,我们将使用这个版本,因为它足够简单,可以使我们使用 Unity 进行脚本编写的介绍更容易,但在具有复杂输入的游戏中,我们建议寻找更高级的工具。

现在,对于 Visual Scripting 版本,图将看起来像这样:

图 6.10:Visual Scripting 中的输入移动

如你所见,与 C#版本相比,图的大小显著增加,这作为了开发者为什么更喜欢编码而不是使用可视化工具的例子。当然,我们有几种方法可以将这个图分成更小的块,使其更易于阅读。此外,我需要将节点挤压在一起,以便它们在同一张图像中。

在前面的屏幕截图中,我们只能看到一个用于前后移动的示例图,但你可以很容易地根据这个图推断出横向移动所需的步骤。像往常一样,你还可以查看项目的 GitHub 仓库以查看完成的文件。

注意所有与 C#版本的相似之处;我们将If节点链接到On Update事件节点,如果第一个If节点条件为true,它将在玩家的前进方向上执行Translate。如果该条件为false,我们将False输出节点链接到另一个检查其他按键压力的If节点,然后,我们使用Multiply (Scalar)节点反向速度来向后移动。你可以看到像If这样的节点,它们有多个Flow Output引脚来分支代码的执行。

还要注意GetKey (Key)节点的使用,这是之前使用的相同GetKey函数的 Visual Scripting 版本。当你在搜索框中查看此节点时,你会看到该函数的所有版本,在这种情况下,我们选择了GetKey(Key)版本;接收名称(字符串)的那个版本工作方式不同,所以我们不涉及那个:

图 6.11:所有版本的 Input GetKey

我们还使用了Or节点将两个GetKey (Key)函数组合成一个条件,提供给If。这些条件运算符可以在搜索框的逻辑类别中找到:

图 6.12:布尔逻辑运算符

有一个要点需要强调的是乘法节点用于将速度变量的值乘以-1。我们需要创建一个浮点文字节点来表示-1 的值。然后,当然,所有程序员都会注意到一些关于我们如何使用If节点的TrueFalse输出引脚的限制,但我们将稍后解决这个问题。最后,考虑到这种实现的问题是在第一次读取成功后阻止第二次输入读取;我们将在本节稍后添加旋转到我们的角色时讨论解决这个问题。

现在,让我们实现鼠标控制。在本节中,我们只会涵盖鼠标移动的旋转;我们将在下一节中实现发射子弹,实现生成。在鼠标移动的情况下,我们可以得到一个值,表示鼠标在水平和垂直方向上移动了多少。这个值不是一个布尔值,而是一个数字:通常称为的输入类型。轴的值将指示移动的强度,而该数字的符号将指示方向。例如,如果 Unity 的“Mouse X”轴的值为0.5,这意味着鼠标以适中的速度向右移动,但如果它说-1,则快速向左移动,如果没有移动,它将说0。游戏手柄上的摇杆也是如此;名为水平的轴表示常见摇杆上左摇杆的水平移动,所以如果玩家将摇杆完全向左拉,它将说-1

我们可以创建自己的轴来映射其他常见的基于摇杆压力的控件,但对我们游戏来说,默认的轴就足够了。要检测鼠标移动,请按照以下步骤操作:

  1. Update函数内部,在移动if语句旁边使用Input.GetAxis函数,如图下截图所示,以存储这一帧鼠标移动的值:

图 6.13:获取鼠标的水平移动

  1. 使用transform.Rotate函数来旋转角色。这个函数接收 x、y 和 z 轴上要旋转的度数。在这种情况下,我们需要水平旋转,所以我们将使用鼠标移动值作为 y 轴旋转,如图下截图所示:

图 6.14:根据鼠标移动水平旋转对象

  1. 如果你保存并测试这个,你会注意到角色会旋转,但速度非常快或非常慢,这取决于你的电脑。记住,这种类型的值需要可配置,所以让我们在编辑器中创建一个rotationSpeed字段来配置玩家的速度:

图 6.15:速度和旋转速度字段

  1. 现在,我们需要将鼠标移动值乘以速度,因此,根据rotationSpeed,我们可以增加或减少旋转量。例如,如果我们设置旋转速度为0.5,将这个值乘以鼠标移动将使对象以之前速度的一半旋转,如下面的截图所示:

图 6.16:将鼠标移动乘以旋转速度

  1. 保存代码,然后返回编辑器设置旋转速度值。如果不这样做,对象将不会旋转,因为浮点类型字段的默认值是0

图 6.17:设置旋转速度

实现旋转的 Visual Scripting 添加将看起来像这样:

图 6.18:在 Visual Scripting 中旋转

这里要注意的第一件事是Sequence节点的使用。一个输出引脚只能附加到另一个节点,但在这个情况下,On Update需要做两件不同的事情:旋转和移动,每件都是独立的。Sequence是一个节点,它将依次执行所有输出引脚,而不考虑每个节点的结果。您可以在Steps输入框中指定输出引脚的数量;在这个例子中,两个就足够了。

在输出引脚0,第一个引脚,我们添加了旋转代码,这相当直观,因为它基本上与移动代码相同,只是节点略有不同(RotateX, Y, Z)和GetAxis)。然后,为了输出引脚 1,我们附加了检查移动输入的If——这是我们在本节开头所做的。这将导致首先执行旋转,然后是移动。

关于我们之前提到的限制,基本上是这样的事实:我们不能同时执行向前向后移动,因为如果按下向前移动键,第一个If将是真的。由于向后移动键的检查是在假输出引脚中进行的,所以在这种情况下它们不会被检查。当然,作为我们的第一个移动脚本,这可能已经足够了,但考虑侧向移动。如果我们继续使用TrueFalse输出引脚的If链接,我们将面临只能单向移动的场景。因此,我们不能组合例如ForwardRight来斜向移动。

解决这个问题的简单方法是将If节点按顺序放置而不是链接它们,这样就可以检查所有的If节点,就像我们在原始 C#中做的那样。您可以在下一张图片中看到这个示例:

图 6.19:If的顺序

在这里需要考虑的是,可以通过右键单击连接If节点和任何类型节点的线条两端的小圆点来移除这些节点的连接。

制作基本的输入脚本通常很容易,但最难的是制作直观且吸引人的输入。除了遵守用户快速适应你的游戏的标准,比如用空格键或游戏手柄上的 A 按钮进行跳跃,我建议你尝试控制的全表达性。这将使我们更好地理解何时使用拖放、在按住键时蓄力打击并在释放时执行打击,或使用扳机来控制渐进式事物,如汽车的加速/制动等感觉自然。当谈到 AR/VR 体验时,这甚至更有趣,因为可能性是无限的。

现在我们已经完成了我们的移动脚本,我们需要通过探索 Delta Time 的概念来对其进行细化,以确保它在每台机器上都能正常工作。

理解 Delta Time

Unity 的Update循环以电脑能运行的最快速度执行。虽然你可以在 Unity 中设置所需的帧率,但实现它完全取决于你电脑的能力,这受到各种因素的影响,而不仅仅是硬件。这意味着你无法始终保证一致的 FPS。你必须编写你的脚本以处理每个可能的场景。我们当前的脚本以每帧一定的速度移动,这里的每帧部分很重要。

我们已经将移动速度设置为 0.1,所以如果我的电脑以 120 FPS 运行游戏,玩家每秒将移动 12 米。现在,在一个游戏以 60 FPS 运行的电脑上会发生什么呢?正如你所猜想的,它将只移动 6 米每秒,这使得我们的游戏在不同电脑上表现出不一致的行为。这就是 Delta Time 发挥作用的时候。

Delta Time是一个告诉我们自上一帧以来过去了多少时间的值。这个时间很大程度上取决于我们的游戏图形、实体数量、物理体、音频以及无数方面,这些都会决定你的电脑可以多快地处理一帧。例如,如果你的游戏以 10 FPS 运行,这意味着在一秒钟内,你的电脑可以处理Update循环 10 次,这意味着每个循环大约需要0.1秒;在帧中,Delta Time 将提供这个值。

在下面的图中,你可以看到四个帧处理不同时间的示例,这在现实生活中可能会发生:

图片

图 6.20:游戏中不同帧的 Delta Time 值变化

在这里,我们需要编写代码以改变移动的 每帧 部分,变为 每秒;我们需要在不同计算机上保持每秒一致的移动。一种方法是按比例移动到 Delta Time;Delta Time 值越高,帧越长,移动应该越远以匹配自上次更新以来经过的真实时间。我们可以将 speed 字段的当前值视为 0.1 米每秒;我们的 Delta Time 表示 0.5 意味着已经过去了半秒,所以我们应该移动一半的速度,0.05

经过两帧一秒后,帧移动的总和(2 x 0.05)与目标速度 0.1 匹配。Delta Time 可以解释为已经过去的一秒的百分比。

要让 Delta Time 影响我们的移动,我们只需在每一帧将我们的速度乘以 Delta Time,因为每一帧都可能不同。所以让我们这样做:

  1. 我们使用 Time.deltaTime 访问 Delta Time。我们可以通过在每次 Translate 中乘以 Delta Time 来开始影响移动:

图 6.21:将速度乘以 Delta Time

  1. 我们可以通过链式鼠标和速度乘法来对旋转速度做同样的事情:

图 6.22:将 Delta Time 应用到旋转代码中

如果你保存并玩这个游戏,你会注意到移动速度会比之前慢。这是因为现在 0.1 是每秒的移动量,意味着每秒 10 厘米,这相当慢;试着提高这些值。在我的情况下,速度是 10,旋转速度是 180,但旋转速度取决于玩家的偏好灵敏度,这可以配置,但让我们留到下次再说。

旋转的视觉脚本更改将看起来像这样:

图 6.23:将 Delta Time 应用到旋转视觉脚本

对于移动,你可以很容易地从这个例子中推断出来,或者查看 GitHub 上的项目。我们只是链式连接了另一个 Multiply 节点与 Get Delta Time

我们刚刚学习了如何将 Unity 的输入系统与基本的 Transform 移动功能混合,这告诉我们键盘、鼠标和其他输入设备的状态。这样,我们可以开始让我们的游戏感觉更加动态。

现在我们已经完成了玩家的移动,让我们讨论如何使用 Instantiate 函数让玩家射击子弹。

实现生成

我们在编辑器中创建了大量的对象来定义我们的关卡,但一旦游戏开始,根据玩家的操作,必须创建新的对象以更好地适应由玩家交互生成的场景。敌人可能过一段时间后出现,或者必须根据玩家的输入创建子弹;即使敌人死亡,也有可能出现一个增益物品。这意味着我们无法事先创建所有必要的对象,而应该动态地创建它们,这通过脚本完成。

在本节中,我们将检查以下生成概念:

  • 生成对象

  • 定时动作

  • 销毁对象

我们将开始看到 Unity 的Instantiate函数,它允许我们在运行时创建 Prefab 的实例,例如在按下一个键时,或者以基于时间的模式,例如让我们的敌人每隔一段时间发射子弹。此外,我们还将学习如何销毁这些对象,以防止我们的场景因为处理过多的对象而开始表现不佳。

让我们从如何根据玩家的输入射击子弹开始。

生成对象

要在运行时或播放模式下生成对象,我们需要对象的描述、它拥有的组件、其设置以及可能的子对象。你可能在这里想到了 Prefab,你是对的;我们将使用一个指令来告诉 Unity 通过脚本创建 Prefab 的实例。请记住,Prefab 的实例是基于 Prefab 创建的对象——基本上是原始对象的克隆。

我们将首先射击玩家的子弹,所以首先,让我们按照以下步骤创建子弹 Prefab:

  1. GameObject | 3D Object | Sphere中创建一个球体。如果您想,可以用另一个子弹模型替换球体网格,但在此例中我们将保持球体不变。

  2. 将球体重命名为Bullet

  3. 通过点击项目窗口的+按钮,选择材料选项,并将其命名为Bullet来创建一个材料。请记住将其放置在Materials文件夹内。

  4. 在材料中勾选发射复选框,并将发射贴图基础贴图颜色设置为红色:

图片

图 6.24:创建具有发射颜色的红色子弹材料

  1. 通过将材料拖动到球体上,将材料应用到球体上。

  2. 缩放设置为较小的值——在我的例子中0.3, 0.3, 0.3有效。

  3. 创建一个名为ForwardMovement的脚本,使子弹以固定速度持续向前移动。您可以使用 C#和视觉脚本创建它,但为了简单起见,我们在此情况下只使用 C#。

    我建议您先自己尝试解决这个问题,并在下一步查看带有解决方案的截图,作为一个小挑战,回顾我们之前看到的运动概念。如果您不记得如何创建脚本,请参考第五章释放 C#和视觉脚本的威力,并查看前面的部分以了解如何移动对象。

  4. 下一个截图显示了脚本应该的样子:

图 6.25:一个简单的 ForwardMovement 脚本

  1. 将脚本(如果尚未添加)添加到子弹上,并将速度设置为合适的值。通常,子弹比玩家快,但这取决于你想要的游戏体验。在我的情况下,20就足够了。通过将子弹放置在玩家附近并玩游戏来测试它:

图 6.26:在 bullet 中的 ForwardMovement 脚本

  1. 将子弹GameObject实例拖到Prefabs文件夹中,创建一个Bullet预制件。请记住,预制件是一个资产,它描述了创建的子弹,就像创建子弹的蓝图:

图 6.27:创建预制件

  1. 从场景中删除原始子弹;当玩家按下键时(如果有的话),我们将使用预制件来创建子弹。

现在我们已经有了子弹预制件,当玩家按下键时,是时候实例化它(克隆它)了。为此,请按照以下步骤操作:

  1. 在玩家的GameObject上创建并添加一个名为PlayerShooting的脚本,并打开它。

  2. 我们需要一种方法让脚本能够访问预制件,以便知道从可能的项目中的几十个预制件中选择哪个。我们脚本需要的所有数据,这取决于期望的游戏体验,都是以字段的形式存在的,例如之前使用的速度字段。因此,在这种情况下,我们需要一个GameObject类型的字段——一个可以引用或指向特定预制件的字段,这可以通过编辑器设置。

  3. 添加字段代码将看起来像这样:

    图 6.28:预制件引用字段

    如你所猜,我们可以使用GameObject类型来引用不仅预制件,还可以引用其他对象。想象一下,一个敌人 AI 需要引用玩家对象来获取其位置,使用GameObject来连接两个对象。这里的技巧是考虑到预制件只是存在于场景外的普通GameObject;你无法看到它们,但它们在内存中,准备好被复制或实例化。你只能通过脚本或编辑器放置在场景中的副本或实例来看到它们,就像我们到目前为止所做的那样。

  4. 在编辑器中,点击属性右侧的圆圈并选择Bullet预制件。另一个选项是将Bullet预制件直接拖到属性上。这样,我们就告诉我们的脚本要发射的子弹是那个特定的一个。请记住,拖动的是预制件而不是场景中的子弹(现在应该已经删除了):

图 6.29:设置预制件引用以指向子弹

  1. 当玩家按下左鼠标按钮时,我们将发射子弹,所以请在Update事件函数中放置适当的if语句来处理,就像下一个截图所示:

图 6.30:检测左鼠标按钮的压力

  1. 你会注意到这次,我们使用了GetKeyDown而不是GetKey,前者是一种检测按键压力开始的确切帧的方法;这个if语句仅在那一帧执行其代码,直到按键被释放并重新按下,它才不会再次进入。这是防止子弹在每一帧生成的一种方法,但为了好玩,你可以尝试使用GetKey来检查它会如何表现。此外,KeyCode.Mouse0是鼠标左键的按钮编号,KeyCode.Mouse1是右键,而KeyCode.Mouse2是中键。

  2. 使用Instantiate函数克隆 Prefab,将对其的引用作为第一个参数传递。这将创建一个上述 Prefab 的克隆,并将其放置在场景中:

图片

图 6.31:实例化 Prefab

如果你保存脚本并玩游戏,你会注意到当你按下鼠标时,会生成子弹,但可能不是你期望的位置。如果你看不到它,请检查层次结构中的新对象;它会在那里。这里的问题是我们没有指定所需的生成位置,并且我们有两种方法可以设置它,我们将在下一步中看到:

  1. 第一种方法是使用从MonoBehaviour继承的transform.positiontransform.rotation字段,这将分别告诉我们我们的当前位置和旋转。我们可以将它们作为Instantiate函数的第二个和第三个参数传递,这样它就会理解这是我们希望子弹出现的位置。记住,设置旋转以使子弹面向与玩家相同的方向是很重要的,这样它才会以这种方式移动:

图片

图 6.32:在当前位置和旋转中实例化 Prefab

  1. 第二种方法是通过使用之前的Instantiate版本,但保存函数返回的引用,这将指向 Prefab 的克隆。这允许我们更改我们想要的任何内容。在这种情况下,我们需要以下三条线:第一条将实例化并捕获克隆引用,第二条将设置位置,第三条将设置旋转。我们还将使用克隆的transform.position字段,但这次是通过使用=(赋值)运算符来更改其值:

图片

图 6.33:在特定位置实例化 Prefab 的较长版本

记住,你可以检查在前言中链接的项目 GitHub 仓库,以查看完整的脚本。现在,你可以保存文件的一个版本并尝试射击。

如果您尝试运行到目前为止的脚本,您应该会看到子弹在玩家的位置生成,但在我们的情况下,它可能是在地板上。这里的问题是玩家的角色枢轴就在那里,通常,每个类人角色都有枢轴在他们的脚上。我们有几种方法可以解决这个问题。最灵活的方法是创建一个 Shoot Point,它是玩家的一个空 GameObject 子对象,放置在我们希望子弹生成的位置。我们可以通过以下步骤使用该对象的坐标而不是玩家的坐标:

  1. GameObject | Create Empty 中创建一个空的 GameObject。命名为 ShootPoint

  2. 将其设置为玩家的 GameObject 的子对象,并将其放置在您希望子弹出现的位置,可能稍微高一点,再往前一些:

图片

图 6.34:在角色内部放置一个空的 ShootPoint 对象

  1. 与往常一样,要访问另一个对象的数据,我们需要一个对该对象的引用,例如 Prefab 引用,但这次,它需要指向我们的 ShootPoint。我们可以创建另一个 GameObject 类型字段,但这次,拖动 ShootPoint 而不是 Prefab。脚本和对象设置将如下所示:

图片

图 6.35:Prefab 和 Shoot Point 字段以及它们在编辑器中的设置

  1. 我们可以通过使用其 transform.position 字段再次访问 ShootPoint 的位置,如下面的截图所示:

图片

图 6.36:在射击生成点定位子弹克隆体

ForwardMovement 的视觉脚本版本看起来是这样的:

图片

图 6.37:使用视觉脚本进行 ForwardMovement

PlayerShooting 将看起来像这样:

图片

图 6.38:在 PlayerShooting 视觉脚本中实例化

如您所见,我们添加了一个名为 Player Shooting 的新图的新 Script Machine 组件。我们还添加了一个新的变量,bulletPrefab,类型为 GameObject,并将 Bullet Prefab 拖动到它上面,以及一个名为 shootPoint 的第二个 GameObject 类型变量,以便引用子弹的生成位置。脚本的其他部分基本上是 C#版本的对应物,没有太大差异。

这里需要强调的是,我们如何将 Transform GetPositionTransform GetRotation 节点连接到 shootPoint 所属的 GetVariable 节点;这样,我们就可以访问射击点的位置和旋转。如果您没有指定,它将使用玩家的位置和旋转,在我们的模型中,这通常是玩家的角色脚部。

你会注意到,现在使用鼠标射击和旋转出现了问题;当移动鼠标进行旋转时,指针会落在游戏视图之外,当你点击时,你会不小心点击到编辑器,从而失去对游戏视图的焦点,因此你需要再次点击游戏视图来恢复焦点并再次使用输入。防止这种情况的一种方法是游戏时禁用光标。为此,请按照以下步骤操作:

  1. 将一个Start事件函数添加到我们的玩家移动脚本中。

  2. 将以下截图中的两行代码添加到你的脚本中。第一行将使光标可见,第二行将锁定光标在屏幕中间,这样它就永远不会离开游戏视图。考虑后者;当你切换回主菜单或暂停菜单时,你需要重新启用光标,以便鼠标可以点击 UI 按钮:

图片

图 6.39:禁用鼠标光标

  1. 保存并测试。如果你想停止游戏,可以按Ctrl + Shift + P(在 Mac 上为Command + Shift + P)或Esc键来重新启用鼠标。这两个选项仅在编辑器中有效;在真实游戏中,你需要将Cursor.visible重置为true,将Cursor.lockState重置为CursorLockMode.None

  2. 视觉脚本等效的代码如下:

图片

图 6.40:在视觉脚本中禁用鼠标光标

现在我们已经介绍了对象生成的基础知识,让我们通过结合定时器来查看一个高级示例。

定时动作

与生成不完全相关,但通常一起使用,定时动作是视频游戏中的常见任务。想法是安排某事在以后发生;也许我们希望子弹在一段时间后销毁以防止内存溢出,或者我们想要控制敌人的生成速率或它们应该何时生成。这正是我们将在本节中要做的,从实现敌人波次开始。

想法是我们希望在游戏的各个不同时刻以一定的速率生成敌人;也许我们希望在游戏开始的前 5 秒内以每秒 2 个的速率生成敌人,总共生成 10 个敌人,给玩家最多 20 秒的时间来完成它们,然后编程下一波,从第 25 秒开始。当然,这很大程度上取决于你想要的精确游戏,你可以从一个像这样的想法开始,并在测试后进行修改,以找到你想要的波次系统工作的确切方式。在我们的案例中,我们将通过实现一个简单的波次系统来应用定时。

首先,我们需要一个敌人,目前我们将简单地使用与玩家相同的 3D 模型,但添加一个向前移动脚本,使其简单地向前移动;在本书的后面部分,我们将为敌人添加 AI 行为。我建议你首先自己尝试创建这个预制件。在你完成之后,参考以下步骤来比较你的工作与推荐方法,并确认你是否正确完成了:

将下载的 Character FBX 模型拖到场景中创建其另一个实例,但这次命名为 Enemy

  1. 添加为子弹创建的 ForwardMovement 脚本,但这次作为 Enemy 使用,并暂时将其速度设置为 10

  2. Enemy 游戏对象拖到 项目 面板中创建其 Prefab;我们稍后会需要它。请记住选择 Prefab 变体 以保持此 Prefab 与原始模型链接,允许对模型的任何更改自动更新 Prefab。

  3. 还要记得从场景中删除原始的 Enemy

    现在,为了安排动作,我们将使用 Invoke 函数创建计时器。它们很简单,但足以满足我们的需求。让我们按照以下步骤使用它们:

  4. 在基座的一端创建一个空的游戏对象,并将其命名为 Wave1a

  5. 向其添加一个名为 WaveSpawner 的脚本。

  6. 我们的生成器需要四个字段:要生成的 Enemy Prefab、波浪的 startTimeendTime 以及敌人的生成速率(每次生成之间应该有多少时间)。脚本和设置将类似于以下截图:

    图 6.41:波浪生成器脚本的字段

    我们将使用 InvokeRepeating 函数来安排一个自定义函数以周期性重复执行。您只需要调度一次重复;Unity 会记住这一点,所以不要为每一帧都做这件事。这是使用 Start 事件函数的一个很好的理由。函数的第一个参数是一个字符串(引号之间的文本),包含要周期性执行的另一个函数的名称,并且与 StartUpdate 不同,您可以随意命名该函数。第二个参数是开始重复的时间——在这个例子中是我们的 startTime 字段。最后,第三个参数是函数的重复率——每次重复之间需要经过多少时间——这就是 spawnRate 字段。

    您可以在下一张截图和自定义的 Spawn 函数中找到如何调用该函数:

    图 6.42:安排重复的 Spawn 函数

  7. Spawn 函数内部,我们可以使用 Instantiate 函数添加生成代码。想法是在一定频率下调用此函数,每次调用生成一个敌人。这次,生成位置将与生成器相同,所以请小心放置:

    图 6.43:在 Spawn 函数中实例化

    如果你通过将预制件的startTimespawnRate字段设置为大于 0 的值来测试这个脚本,你会注意到敌人会开始生成但永远不会停止,你可以看到我们还没有使用endTime字段。想法是调用CancelInvoke函数,这是唯一一个可以取消我们做出的所有InvokeRepeating调用的函数,但过一段时间后。我们将使用Invoke函数延迟执行CancelInvoke,它的工作方式与InvokeRepeating类似,但这个函数只执行一次。在下一个截图中,你可以看到我们如何在Start中添加了对CancelInvoke函数的Invoke调用,使用endTime字段作为执行CancelInvoke的时间。这将延迟执行CancelInvoke,取消生成预制件的第一个InvokeRepeating调用:

    图 6.44:安排生成重复,但使用 CancelInvoke 在一段时间后取消

    这次,我们使用Invoke来延迟调用CancelInvoke。我们没有创建一个自定义函数,因为CancelInvoke不接受任何参数。如果你需要安排一个带有参数的函数,你需要创建一个包装函数;这个函数本身没有参数,但用于调用你的目标函数(你最初打算使用的函数)并传递必要的参数。这种方法与我们处理Spawn函数的方式类似,其中包装函数的唯一目的是使用特定的参数调用Instantiate方法。

  8. 现在,你可以为你的生成器保存并设置一些真实值。在我的例子中,我使用了以下截图所示的内容:

图 6.45:在游戏开始后的 5 秒内,每 0.5 秒生成一次敌人,每次生成 2 个

你应该看到敌人挨着生成,由于它们向前移动,它们将排成一行。这种行为将在我们未来章节中使用 Unity 的 AI 功能时改变。现在,视觉脚本版本将看起来像这样:

图 6.46:在视觉脚本中生成敌人

虽然我们可以在视觉脚本中使用InvokeRepeating方法,但在这里我们可以看到视觉方法的一些好处,因为它有时比编码更具灵活性。在这种情况下,我们在Start的开始处使用了Wait For Seconds节点,这个节点基本上会暂停流程的执行几秒钟。这将创建原始脚本中存在的初始延迟;这就是为什么我们使用了startTime作为Delay的数量。

现在,在等待之后,我们使用了一个For循环;在这个例子中,我们改变了脚本的概念,因为我们想要生成特定数量的敌人,而不是在设定的时间内生成。For循环本质上是一个经典的For循环,它将重复连接到Body输出引脚的内容,重复次数由Last输入引脚指定的次数。

我们将那个引脚连接到一个变量,以控制我们想要生成的敌人数量。然后,我们将For循环的Body输出引脚连接到Instantiate,以实例化我们的敌人,然后连接一个Wait For Seconds节点,在循环可以继续生成敌人之前停止流程一段时间。

有趣的是,如果你现在玩游戏,你将在控制台收到一个看起来像这样的错误:

图 6.47:使用 Wait 节点时的错误

你甚至可以回到 Graph Editor,并看到冲突的节点将以红色突出显示:

图 6.48:导致错误的节点

这里的问题是,为了让Wait For Seconds节点工作,你需要将Start事件标记为Coroutine。这基本上将允许事件暂停一段时间,然后稍后继续。

协程的概念存在于 C#中,但由于在 Visual Scripting 中比在 C#中更容易实现,我们决定采用这种方法。如果你想了解更多信息,请查看以下文档:docs.unity3d.com/Manual/Coroutines.html

要解决这个错误,请选择On Start事件节点,并在Script Graph编辑器的左侧的Graph Inspector面板中检查Coroutine复选框。如果你看不到它,请尝试在编辑器的左上角点击Info按钮(带i的圆圈)。

协程是一种可以被暂停并在稍后继续执行的功能,这正是Wait节点所做的事情。协程也存在于MonoBehaviours中,但现在让我们保持简单。

图 6.49:将开始标记为协程

现在我们已经讨论了时间和生成,让我们讨论时间和Destroy,以防止我们的子弹在内存中永远存在。

销毁对象

我们可以使用Destroy函数来销毁对象实例。想法是让子弹有一个脚本,在一段时间后安排自己的自动销毁,以防止它们永远存在。我们将通过以下步骤创建脚本:

  1. 选择Bullet的 Prefab,并使用Add Component | New Script选项添加一个名为Autodestroy的脚本,就像你为其他对象所做的那样。这次,脚本将添加到 Prefab 中,并且你生成的 Prefab 的每个实例都将拥有它。

  2. 你可以使用Destroy函数,如下一张截图所示,在Start中一次性销毁对象:

    图 6.50:对象开始时销毁

    Destroy函数期望将对象作为第一个参数,在这里,我们使用gameObject引用——这是一种指向放置脚本的 GameObject 的方式,以便将其销毁。如果你使用this指针而不是GameObject,我们只会销毁我们正在创建的Autodestroy组件。

    当然,我们不想在子弹被生成后立即销毁它,所以我们需要延迟销毁。你可能正在考虑使用Invoke,但与 Unity 中的大多数函数不同,Destroy可以接收第二个参数,即等待销毁的时间。

  3. 创建一个延迟字段,将其用作Destroy的第二个参数,如下一个截图所示:

图片

图 6.51:使用字段配置销毁对象的延迟

  1. delay字段设置为适当的值;在我的情况下,5 就足够了。现在,通过观察它们从层次结构中被移除,检查子弹经过一段时间后是如何消失的(即被移除)。

  2. 视觉脚本等效的代码如下所示:

图片

图 6.52:在视觉脚本中销毁

关于这个版本,注意我们如何使用组件销毁ObjT)版本的Destroy节点,它包括延迟时间。此外,寻找Object Pool概念,这是一种回收对象而不是不断创建对象的方法;你将了解到有时创建和销毁对象并不那么高效。

现在,我们可以随意创建和销毁对象,这在 Unity 脚本中是非常常见的。在下一节中,我们将讨论如何修改我们迄今为止创建的脚本以支持新的 Unity 输入系统。

使用新的输入系统

我们一直使用输入类来检测被按下的按钮和轴,对于我们的简单使用,这已经足够了。然而,默认的 Unity 输入系统在扩展性方面有其局限性,无法支持新的输入硬件和映射。

在本节中,我们将探讨以下概念:

  • 安装新的输入系统

  • 创建输入映射

  • 在脚本中使用映射

让我们开始探索如何安装新的输入系统。

安装新的输入系统

要开始使用新的输入系统,它需要像我们迄今为止安装的任何其他包一样安装,使用包管理器。这个包只是叫做输入系统,所以按照常规安装即可。在这种情况下,我们使用版本 1.7.0,但当你阅读这一章时,可能有一个更新的版本可用。

图片

图 6.53:安装新的输入系统包

默认情况下,当你安装输入系统时,它将提示你启用新的输入系统,窗口如下面的图像所示。如果出现,只需点击并等待 Unity 重新启动:

图片

图 6.54:切换活动输入系统

如果由于某种原因它没有出现,另一种选择是编辑 | 项目设置然后玩家 | 其他设置 | 配置,并将活动输入处理属性设置为输入系统包(新)。

有一个名为Both的选项,可以同时启用旧输入系统和新输入系统,但让我们只使用一个。

图 6.55:切换活动输入系统

现在我们已经安装并设置了系统,让我们来探索如何创建所需的输入映射。

创建输入映射

新系统有一种直接请求游戏手柄、鼠标、键盘或我们拥有的任何其他设备(如我们之前在输入系统中所做的那样)的按钮或摇杆当前状态的方法。然而,使用这种方法将阻止我们使用系统的最佳功能之一,即输入映射。

输入映射的想法是将输入动作从物理输入中抽象出来。不是考虑空格键、游戏手柄的左侧摇杆或鼠标的右键点击,而是从动作的角度思考,比如移动、射击或跳跃。在代码中,你询问是否按下了射击按钮,或者移动轴的当前值,就像我们用鼠标轴旋转所做的那样。虽然先前的系统支持一定程度的输入映射,但新输入系统的功能更强大,配置也更简单。

动作 映射
射击 左鼠标按钮、左控制键和游戏手柄的 X 按钮
跳跃 空格键,游戏手柄的 Y 按钮
水平移动 AD 键,左右箭头,以及游戏手柄的左侧摇杆

表 6.01:输入映射表示例

这个想法的力量在于,实际触发这些动作的键或按钮可以在 Unity 编辑器中配置,允许任何游戏设计者更改控制整个游戏的精确键,而无需更改代码。

我们甚至可以将多个按钮映射到同一动作,即使来自不同的设备,这样我们就可以使鼠标、键盘和游戏手柄触发相同的动作,极大地简化了我们的代码。另一个好处是,用户还可以通过我们添加到游戏中的某些自定义 UI 重新绑定按键,这在 PC 游戏中非常常见。

创建输入映射的最简单方法是通过玩家输入组件。正如其名所示,该组件代表特定玩家的输入,使我们能够在游戏中每个玩家身上都有一个,以支持分屏多人游戏,但让我们先专注于单人游戏。将此脚本添加到我们的玩家中,将允许我们使用创建动作按钮创建默认的输入映射资产。这个资产,作为一个材料,可以被多个玩家使用,因此我们将对其进行修改,使其影响所有玩家(例如,通过添加Jump输入映射):

图 6.56:使用玩家输入组件创建输入动作资产

在点击创建动作按钮并保存资产位置到保存提示后,您将看到以下屏幕:

图 6.57:默认输入映射文件

从这个资产中,我们首先需要理解的是动作映射部分(左侧面板)。这允许我们为不同的情况创建单独的动作映射,例如,在像《侠盗猎车手》(GTA)这样的游戏中,为驾驶和步行控制创建不同的动作映射。默认情况下,玩家UI映射被创建来分离玩家控制和导航 UI 的映射。如果您再次检查玩家输入组件,您会看到默认映射属性被设置为玩家,这意味着我们将只关注在这个 GameObject 中控制输入映射的玩家;任何 UI 动作的按下都不会被考虑。我们可以在运行时随意切换活动映射,例如,在暂停菜单中禁用角色控制器输入,或者在车内切换到驾驶映射,使用相同的按钮,但用于其他目的。

如果您在左侧面板中选择动作映射,您将在中间面板的动作列表中看到它包含的所有动作。在玩家的情况下,我们有移动观察射击映射,这些正是我们将在游戏中使用的输入。请注意,如果您需要使用+按钮,您可以添加更多映射,但现在让我们坚持使用默认的映射。当您从列表中选择任何动作时,您将在右侧显示的动作属性面板中看到它们的配置:

图 6.59:移动(左侧)和射击(右侧)动作配置

如您所见,有一个名为动作类型的属性,它将决定我们正在讨论哪种类型的输入。如果您在中间面板中选择移动,您会看到它是一个动作类型,控制类型Vector2,这意味着它将返回 x 轴和 y 轴的值,即水平和垂直的值——这是我们期望从任何游戏手柄的摇杆中得到的类型。在之前的系统中,我们从单独的 1D 轴中获取这些值,例如鼠标 X鼠标 Y轴,但在这里,为了方便起见,它们被合并成一个单一变量。另一方面,射击动作的类型是按钮,它不仅能够检查其当前状态(按下或释放),还能进行诸如是否刚刚被按下或刚刚被释放的检查,这与之前系统中的GetKeyGetKeyDownGetKeyUp功能相当。

现在我们已经了解了我们有哪些动作以及每种动作的类型,让我们讨论物理输入如何触发它们。您可以通过点击中间面板中每个动作左侧的箭头来查看其物理映射。让我们开始探索移动动作映射。

在这种情况下,我们有四个映射:

  • 左侧摇杆 [游戏手柄]:游戏手柄的左侧摇杆

  • 主要 2D 轴 [XR 控制器]:VR 控制器的摇杆

  • 摇杆 [操纵杆]:类似街机或甚至飞行杆的主要摇杆

  • WASD:通过WASD键模拟摇杆的复合输入

如果你选择其中的任何一个,你可以检查它们的配置;让我们以左摇杆和 WASD 为例进行比较:

图片

图 6.59:左摇杆映射(左)和 WASD 键映射(右)

左摇杆的情况下,你可以看到允许你选择所有可能提供Vector2值(x 轴和 y 轴)的硬件物理控制的路径属性。在WASD键映射的情况下,你可以看到它是一个类型为2D 向量的复合绑定,正如之前所述,这允许我们通过其他输入(在这种情况下是按键)来模拟 2D 轴。如果你展开中间面板中的WASD输入映射,你可以看到为这个 2D 轴组合的所有输入及其配置,通过选择它们来查看:

图片

图 6.60:考虑的 WASD 复合 2D 轴的输入

在前面的例子中,它不仅映射了WASD按钮,还映射了四个键盘箭头。每个映射不仅有一个选择物理按钮的路径,还有一个复合部分设置,允许我们指定这个输入将拉动模拟摇杆的方向。

就这样,我们刚刚触及了这个系统所能做到的一小部分,但到目前为止,让我们保持简单,并使用这些设置。记住,在项目的根目录中创建了一个与我们的游戏同名的新资产(在我们的例子中是SuperShooter)。你可以随时通过双击它来重新打开这个动作映射窗口。现在让我们看看我们如何在代码中使用这些输入。

这个系统还能为我们做更多的事情。一个例子是交互,它允许我们执行诸如在按下X段时间内触发动作等操作。另一个例子是复合,当按下按键组合时触发动作。有关更多信息,请查看输入系统包文档:docs.unity3d.com/Packages/com.unity.inputsystem@1.6

在我们的脚本中使用映射

这个输入系统提供了几种检测输入状态的方法。玩家输入组件有一个行为属性,可以在一些可用模式之间切换。最简单的一个是称为发送消息的模式,这是我们将在按键按下时在代码中执行方法所使用的模式。在这个模式下,映射中的每个动作都将有自己的事件,你可以在组件底部的工具提示中看到所有这些。随着你添加映射,将出现更多。

图片

图 6.61:默认映射的所有输入事件

想要了解更多关于其他 PlayerInput 行为模式的信息,请查看以下文档:docs.unity3d.com/Packages/com.unity.inputsystem@1.8/manual/PlayerInput.html#notification-behaviors

从列表中,我们需要OnMoveOnLookOnFire。我们可以修改我们的PlayerMovement脚本以使用它们,如下面的截图所示:

图 6.62:使用新输入系统的 PlayerMovement

你首先会注意到的一个不同点是,我们不再像以前那样在Update方法中请求输入状态。相反,我们监听OnMoveOnLook事件,这些事件为我们提供了一个包含那些轴当前状态的InputValue参数。想法是,每当这些轴的值发生变化时,这些事件就会执行,如果值没有变化,比如当玩家一直将摇杆推到最右边时,它们就不会执行。这就是为什么我们需要在movementValuelookValue变量中存储当前值——以便在Update方法中稍后使用轴的最新值,并在每一帧中应用移动。将这些视为私有,意味着它们不会出现在编辑器中,但对我们来说这没问题。此外,注意我们在文件顶部添加了using UnityEngine.InputSystem行,以启用脚本中新的输入系统的使用。

在这个版本的PlayerMovement脚本中,我们使用了与鼠标相同的轴输入类型,但这次也用于移动,而之前的版本使用的是按钮。这通常是首选选项,所以我们将坚持使用这个版本。观察我们如何使用单个transform.Translate来移动;我们需要使用movementValue的 x 轴来移动玩家的 x 轴,但我们使用movementValue的 y 轴来移动玩家的 z 轴。我们不希望玩家垂直移动,这就是为什么我们需要以这种方式拆分轴。

InputValue参数具有Get<Vector2>()方法,它将给出两个轴的当前值,前提是Vector2是一个包含 x 和 y 属性的变量。然后,我们根据情况将向量乘以移动或旋转速度。你会注意到在轴事件中我们没有乘以Time.deltaTime,但在Update中我们这样做。这是因为Time.deltaTime可以在帧之间变化,所以考虑我们上次移动摇杆时的Time.deltaTime来存储移动值对我们来说将没有用。此外,注意movementValue是一个Vector2,仅仅是 x 和 y 轴的组合,而lookValue是一个简单的浮点数。我们这样做是因为我们将只通过跟随鼠标的横向移动来旋转我们的角色;我们不希望它上下旋转。检查我们提取value.Get<Vector2>().x,重点在于.x部分,我们只提取轴的横向部分用于计算。

关于PlayerShooting组件,我们需要将其更改为这样:

图片

图 6.63:使用新输入系统的 PlayerShooting 脚本

这种情况比较简单,因为我们不需要为每一帧执行射击行为;我们只需要在输入被按下时执行某些操作,这正是OnFire事件将被执行的时刻。如果你还需要检测键何时被释放,你可以添加InputValue参数,就像我们在OnMoveOnLook中做的那样,并查看isPressed属性:

图片

图 6.64:获取按钮的状态

关于我们脚本的 Visual Script Machine 版本,首先,你需要通过转到编辑 | 项目设置 | 视觉脚本并点击重新生成节点按钮来刷新视觉脚本节点库。如果你不这样做,你将看不到新的输入系统节点:

图片

图 6.65:重新生成支持新输入系统的视觉脚本节点

现在,PlayerShooting视觉脚本应该看起来像这样:

图片

图 6.66:使用新输入系统实例化子弹

新的输入系统事件按钮节点允许我们检测动作按钮何时被按下,并相应地做出反应。你可以在输入动作参数中选择特定的动作,你甚至可以使用节点标题下面的选项使节点对按钮的压力、释放或保持状态做出反应。存在一个错误,即输入动作属性可能不会显示任何选项;在这种情况下,尝试在图中重新添加和删除节点,并确保你添加了ScriptMachine组件到具有PlayerInput组件的同一 GameObject。此外,请确保你在层次结构中选择了 Player GameObject。

关于移动,可以这样实现:

图片

图 6.67:使用新的输入系统移动

在这个例子中,我们使用了 On Input System Event Vector2 节点。这次,我们使用了 OnHold 模式,这意味着与 C# 版本不同,它不会仅在轴变化时执行,而是在轴被按下的所有帧都像 Update 一样执行;然而,这只有在用户按下摇杆时才会执行。节点的输出引脚是 Vector2 值,因此我们将其乘以 speed 变量(在玩家的 Variables 组件中声明)和 DeltaTime。最后,我们使用 Vector2 GetXVector2 GetY 节点在 x 和 z 轴上平移。由于与之前使用的节点(Vector2 而不是单个浮点数)的返回类型不同,您可能会在重新连接新的 Input System 节点时遇到麻烦。我建议删除此图中的所有节点并重新创建它们,以确保一切正常。这样,我们使我们的脚本能够对 Unity 新输入系统的输入消息做出反应。

摘要

在本章中,我们创建了第一个真正的脚本,这些脚本提供了有用的行为。我们讨论了如何根据输入移动 GameObject 以及通过脚本实例化 Prefabs,根据游戏情况随意创建对象。我们还看到了如何安排动作——在这种情况下,生成——但这可以用来安排任何事物。我们看到了如何销毁创建的对象以避免对象数量增加到无法管理的水平。最后,我们探索了新的输入系统,以提供最大的灵活性来自定义游戏的输入。我们将在本书的后面部分使用这些动作来创建其他类型的对象,例如声音和效果。

现在,您能够创建任何类型的移动或生成逻辑,您的对象将需要这些逻辑,并且您可以在需要时确保这些对象被销毁。您可能会认为所有游戏都以相同的方式移动和创建射击系统,尽管它们是相似的,但能够创建自己的移动和射击脚本允许您自定义游戏中的这些方面,使其按您的意图行事,创造出您所寻找的精确体验。

在下一章中,我们将讨论如何检测碰撞以防止玩家和子弹穿过墙壁以及更多内容。

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家和作者本人一起阅读此书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第七章:碰撞与健康:准确检测碰撞

随着游戏试图模拟现实世界的表现,模拟的一个重要方面是物理,它决定了物体如何移动以及它们如何相互碰撞,例如玩家与墙壁或子弹与敌人的碰撞。由于碰撞后可能发生的无数反应,物理可能难以控制,因此我们将学习如何正确配置我们的游戏,以尽可能精确地创建物理效果。这将基于现实世界的碰撞生成期望的街机运动感觉——毕竟,有时候,现实生活并不像电子游戏那样有趣!

在本章中,我们将探讨以下碰撞概念:

  • 配置物理

  • 检测碰撞

  • 使用物理移动

首先,我们将学习如何正确配置物理设置,这是为了使我们的脚本能够检测到物体之间的碰撞,我们将学习新的 Unity 事件。所有这些都需要,以便检测我们的子弹何时到达敌人并对其造成伤害。然后,我们将讨论使用Transform移动,这是我们迄今为止所做的方式,以及使用 Rigidbody 移动之间的区别,以及这两种方法的优缺点。我们将使用这些方法来实验不同的移动玩家方式,并让你决定你想使用哪一种。让我们先从讨论物理设置开始。

配置物理

Unity 的物理系统已经准备好覆盖广泛的可能游戏应用,因此正确配置它对于获得期望的结果非常重要。在本节中,我们将探讨以下物理设置概念:

  • 设置形状

  • 物理对象类型

  • 碰撞过滤

我们将首先了解 Unity 提供的不同类型的碰撞体,然后学习如何配置这些碰撞体以检测不同类型的物理反应(碰撞触发器)。最后,我们将讨论如何忽略特定对象之间的碰撞,以防止玩家子弹损坏玩家等情况。

设置形状

在本书的开头,我们了解到物体通常有两种形状:视觉形状(基本上是 3D 网格)和物理形状(碰撞体——物理系统将使用它来计算碰撞)。请记住,这个想法是允许你围绕简化的物理形状拥有高度详细的可视模型,以提高性能。

Unity 有多种类型的碰撞体,因此在这里,我们将回顾常见的几种,从原始类型开始——即盒子球体胶囊体。这些形状由于碰撞是通过数学公式完成的,因此在检测碰撞方面是最便宜的(从性能角度来看),与其他碰撞体不同,例如网格碰撞体,它允许你使用任何网格作为对象的物理体,但性能成本更高,并且有一些限制。想法是应该使用原始类型来表示你的对象或它们的组合;例如,一架飞机可以用两个盒子碰撞体来制作,一个用于机身,另一个用于机翼。你可以在下面的屏幕截图中找到一个例子,其中可以看到由原始形状制成的武器碰撞体:

图片

图 7.1:复合碰撞体

无论如何,这并不总是必要的;如果我们只想让武器掉到地上,也许一个覆盖整个武器的盒子碰撞体就足够了,考虑到这类碰撞不需要非常精确,从而提高性能。此外,一些形状即使通过原始形状的组合也无法表示,例如斜坡或金字塔,在这种情况下,你唯一的解决方案是使用网格碰撞体,它需要一个 3D 网格用于碰撞。然而,鉴于它们对性能的高影响,我们不会在本书中使用它们;我们将使用原始形状解决所有物理碰撞体。

现在,让我们添加必要的碰撞体到我们的场景中,以便正确地计算碰撞。如果你使用了除了我的以外的 Asset Store 环境包,你可能已经有了带有碰撞体的场景模块;我将展示我需要做的我的案例,但尝试将主要思想扩展到你的场景中。

要添加碰撞体,请按照以下步骤操作:

  1. 在基础中选一个墙,检查对象和可能的子对象是否有碰撞组件;在我的情况下,我没有碰撞体。如果你检测到任何网格碰撞体,你可以保留它,如果你想的话,但我建议你在下一步中将其移除并替换为另一个选项。想法是向其添加碰撞体,但我在这里发现的问题是,由于我的墙不是预制体的实例,我需要为场景中的每个墙添加碰撞体。

  2. 一个选项是创建一个预制体,并用它的实例替换所有墙壁(推荐解决方案);或者,你可以只选择层次结构中的所有墙壁(通过在 Mac 上按住Ctrlcommand键的同时点击它们)并选择它们,然后使用添加组件按钮为它们添加碰撞体。在我的情况下,我将使用Box Collider组件,它将调整碰撞体的尺寸以适应网格。如果它不适应,你只需更改盒子碰撞体组件的大小中心属性,以覆盖整个墙壁:

图片

图 7.2:添加到墙上的盒子碰撞体

  1. 重复步骤 1步骤 2,为角落、地板砖和任何其他会阻挡玩家和敌人移动的障碍物。

现在我们已经为墙壁和地板添加了所需的碰撞器,我们可以继续处理玩家和敌人。我们将为它们添加胶囊碰撞器,这是在可移动角色中使用的常用碰撞器,因为圆形底部将允许对象平滑地爬坡。水平圆形允许对象在角落中轻松旋转而不会卡住,以及其他该形状的便利性。

您可能想要基于我们之前下载的角色之一创建一个敌人 Prefab,这样您就可以将碰撞器添加到该 Prefab 上。我们的玩家是场景中的一个简单 GameObject,所以您需要将碰撞器添加到该 GameObject 上,但请考虑为玩家创建一个 Prefab 以方便操作。

您可能会想为角色的骨骼添加多个盒子碰撞器以创建对象的逼真形状。虽然我们可以使用这种方法根据敌人身体被射击的位置来改变伤害,但请注意,我们主要是在创建运动碰撞器,使用胶囊碰撞器就足够了。在高级伤害系统中,胶囊和骨骼碰撞器将共存,一个用于运动,另一个用于伤害检测,但我们将简化我们的游戏。

此外,有时碰撞器不会很好地适应对象的视觉形状,在我的情况下,胶囊碰撞器与角色不太匹配。我需要通过设置以下截图中的值来修复其形状以匹配角色:中心0,1,0, 半径0.5,和高度2

图片

图 7.3:角色碰撞器

我们用球体创建的子弹已经有一个球体碰撞器,但如果您用另一个网格替换子弹的网格,您可能想要更改碰撞器。目前,我们游戏中不需要其他对象,所以现在所有对象都有了合适的碰撞器,让我们看看如何为每个对象设置不同的物理设置以启用适当的碰撞检测。

如果您检查地形组件,您会看到它有自己的碰撞器类型,即地形碰撞器。对于地形,这是唯一要使用的碰撞器。

物理对象类型

现在我们已经通过使对象在物理模拟中具有存在感为每个对象添加了碰撞器,是时候配置它们以具有我们想要的精确物理行为。我们有无数可能的设置组合,但我们将讨论一组常见的配置文件,这些配置文件涵盖了大多数情况。记住,除了碰撞器之外,我们在本书的开头看到了 Rigidbody 组件,这是将物理应用于对象的一个组件。以下配置文件是通过组合碰撞器和 Rigidbody 设置创建的:

  • 静态碰撞体: 如其名所示,这种碰撞体不应该移动,除了某些特定的例外。大多数环境对象都属于这一类别,例如墙壁、地板、障碍物和地形。这类碰撞体只是没有Rigidbody组件的碰撞体,因此它们存在于物理模拟中,但不会对它们应用任何物理效果;它们不能被其他对象的碰撞移动,它们不会有物理效果,并且无论发生什么情况,它们都将固定在其位置。请注意,这与编辑器右上角的静态复选框无关;那个是为我们将在几章中探讨的系统准备的(例如第十二章启迪世界:使用通用渲染管道照亮场景),因此如果需要,你可以有一个未勾选该复选框的静态碰撞体。

  • 物理碰撞体: 这些是具有Rigidbody组件的碰撞体,就像我们在本书第一部分创建的掉落球体的例子。这些是完全由物理驱动的对象,具有重力并且可以通过力移动;其他对象可以推动它们,并且它们会执行你所能预期的所有其他物理反应。你可以用这个来处理玩家、手榴弹移动、掉落的箱子,或者所有在基于物理的游戏中的对象,例如不可思议的机器

  • 运动学碰撞体: 这些是具有Rigidbody组件但勾选了是运动学复选框的碰撞体。它们对碰撞和力的物理反应与静态碰撞体不同,但预期会移动,允许物理碰撞体在移动时正确处理与它们的碰撞。这些可以用于需要使用动画或自定义脚本移动的对象,例如移动平台。

  • 触发静态碰撞体: 这是一个普通的静态碰撞体,但勾选了碰撞体的是触发复选框。区别在于,当运动学和物理对象通过它时,会自动生成一个Trigger事件;这可以通过脚本捕获,这允许我们检测是否有东西在碰撞体内部。这个事件可以用来在玩家通过发生某些事情的区域时创建按钮或触发对象,例如敌人波生成、门打开,或者如果该区域是玩家的目标,则赢得游戏。请注意,普通的静态碰撞体在通过这种类型时不会生成触发事件,因为它们不应该移动。

  • 触发运动学碰撞体: 运动学碰撞体不会生成碰撞,因此它们会穿过任何其他对象,但它们会生成Trigger事件,因此我们可以通过脚本进行响应。这可以用来创建可移动的奖励物品,当接触时消失并给我们加分,或者子弹以自定义脚本移动,没有物理效果,就像我们的子弹一样,但它们接触其他对象时会造成伤害。

当然,除了指定的配置文件外,还可以存在其他配置文件,用于满足某些游戏的具体游戏玩法要求,但实验所有可能的物理设置组合是否适用于你的情况取决于你;所描述的配置文件将涵盖 99%的情况。

为了回顾之前的场景,请查看以下表格,显示所有类型碰撞器的接触反应。你将找到每个可以移动的配置文件的一行;请记住,静态配置文件不应该移动。每一列代表它们与其他类型碰撞时的反应:“无”表示对象将无效果地穿过,而“触发”表示对象将穿过但会引发“触发”事件,“碰撞”表示第一个对象无法穿过第二个对象:

与静态碰撞 与动态碰撞 与动力学碰撞 与触发静态碰撞 与触发动力学碰撞
动态 碰撞 碰撞 碰撞 触发 触发
动力学 碰撞 触发 触发
触发动力学 触发 触发 触发 触发 触发

表 7.01:碰撞反应矩阵

考虑到这一点,让我们开始配置场景对象的物理属性。

墙壁、角落、地板砖和障碍物应使用静态碰撞器配置文件,因此它们上没有Rigidbody组件,并且它们的碰撞器将不会勾选是触发复选框:

图片

图 7.4:地板砖的配置;请记住,静态复选框仅用于照明

玩家应该移动并与其他物体产生碰撞,因此我们需要玩家拥有一个动态配置文件。这个配置文件将与我们当前的移动脚本(我鼓励你测试)产生有趣的行为,尤其是在与墙壁碰撞时,它不会像你预期的那样表现。我们将在本章后面处理这个问题:

图片

图 7.5:玩家上的动态设置

我们之前建议你创建的Enemy预制件将使用动力学配置文件,因为我们将在本书后面使用 Unity 的 AI 系统移动这个对象,所以这里不需要物理。此外,由于我们希望玩家与Enemy预制件对象发生碰撞,我们需要在这里设置碰撞反应,因此没有触发

图片

图 7.6:敌人的动力学设置

Bullet预制件通过脚本进行简单的移动(它只是向前移动)而不是物理移动。这里我们不需要碰撞;我们将编写代码,使子弹在接触任何东西时立即销毁,并尽可能损坏碰撞的对象,因此动力学触发配置文件就足够了。我们将使用触发事件来编写接触反应的脚本:

图片

图 7.7:子弹的动力学触发设置,已勾选“是触发”和“是动力学”

现在我们已经正确配置了对象,让我们来看看如何过滤某些对象类型之间的不希望发生的碰撞。

碰撞过滤

有时,我们希望某些对象相互忽略,例如玩家射出的子弹,它不应该与玩家本身碰撞。我们总是可以通过 C#脚本中的if语句来过滤它,检查被击中的对象是否来自敌方或任何过滤逻辑,但那时已经太晚了;物理系统浪费了资源,检查了本不应该碰撞的对象之间的碰撞。这就是图层碰撞矩阵能帮到我们的地方。

图层碰撞矩阵听起来很吓人,但它只是物理系统的一个简单设置,允许我们指定哪些对象组应该与其他组发生碰撞。例如,玩家的子弹应该与敌人碰撞,敌人的子弹应该与玩家碰撞。在这种情况下,敌人的子弹会穿过敌人,但这种情况在我们的案例中是可取的。我们的想法是创建组并将我们的对象放入其中;在 Unity 中,这些组被称为图层。我们可以创建图层,并将 GameObject(检查器的顶部部分)的图层属性设置为将对象分配到该组或图层。请注意,您有有限的图层数量,因此请尽量明智地使用它们。

我们可以通过以下方式实现:

  1. 前往编辑 | 项目设置,然后在其中,从左侧面板查找标签和图层选项:

图片

图 7.8:标签和图层设置

  1. 图层部分,填写空白区域以创建图层。我们将使用此功能处理子弹场景,因此我们需要四个图层:PlayerEnemyPlayerBulletEnemyBullet

图片

图 7.9:创建图层

  1. 在层次结构中选择PlayerGameObject,然后从检查器的顶部部分更改图层属性为Player。同时,将EnemyPrefab 的图层更改为Enemy。会出现一个窗口,询问您是否想同时更改子对象;选择

图片

图 7.10:更改玩家和敌人 Prefab 的图层

  1. 在子弹的情况下,我们有一个问题;我们有一个 Prefab 但有两个图层,而 Prefab 只能有一个图层。我们有两种选择:通过脚本根据射击者更改图层或创建两个具有不同图层的bulletPrefab。为了简单起见,我将选择后者,并借此机会将另一种材质应用到敌人子弹上,使其看起来不同。

  2. 我们将为玩家子弹创建一个 Prefab 变体。请记住,变体是基于原始 Prefab 的 Prefab,就像类继承一样。当原始 Prefab 发生变化时,变体也会变化,但变体可以有不同的差异,这将使其独特。

  3. 将子弹 Prefab 拖放到场景中创建一个实例。

  4. 再次将实例拖动到Prefabs文件夹中,这次在出现的窗口中选择Prefab Variant选项。

  5. 将其重命名为Enemy Bullet

  6. 在场景中销毁预制体实例。

  7. 创建一个类似于玩家子弹的第二种材料,颜色不同,并将其放在敌人子弹预制体变体上。

  8. 选择敌人子弹预制体,将其层设置为EnemyBullet,并对原始预制体(PlayerBullet)执行相同的操作。即使你更改了原始预制体的层,由于变体修改了它,修改后的版本(或覆盖)将占优,允许每个预制体有自己的层。

现在我们已经配置了层,让我们配置物理系统以使用它们:

  1. 前往编辑 | 项目设置并查找物理设置(不是物理 2D)。

  2. 滚动直到您看到层碰撞矩阵,一个半网格的复选框。您会注意到每一列和行都标有层的名称,因此每一行和列交叉处的每个复选框都将允许我们指定这两个是否应该碰撞。在我们的情况下,我们配置了层碰撞矩阵,如以下截图所示,以便玩家子弹不会击中玩家或其他玩家子弹,敌人子弹不会击中敌人或其他敌人子弹:

图片

图 7.11:使玩家子弹与敌人碰撞,以及敌人子弹与玩家碰撞

值得注意的是,有时过滤逻辑可能不会那么严格或可预测。例如,它可能只涉及具有一定生命值的击中对象,没有隐形时间增益的对象,或者可以在游戏过程中改变的条件,并且难以为所有可能的层和组生成。因此,在这些情况下,我们应该在触发碰撞事件之后依赖手动过滤。

现在我们已经过滤了碰撞,让我们通过下一节中的碰撞来检查我们的设置是否正常工作。

检测碰撞

如您所见,适当的物理设置可能很复杂且非常重要,但既然我们已经解决了这个问题,让我们通过以不同的方式对接触做出反应来执行一些任务,在这个过程中创建一个健康系统

在本节中,我们将检查以下碰撞概念:

  • 检测触发事件

  • 修改其他对象

首先,我们将探讨 Unity 为我们提供的不同碰撞和触发事件,以便对两个对象之间的接触做出反应,通过 Unity 碰撞事件。这允许我们执行我们想要放置的任何反应代码,但在这里,我们将探讨如何使用GetComponent函数修改接触到的对象组件。

检测触发事件

如果对象配置得当,如前所述,我们可以得到两种反应:碰撞或触发。碰撞反应有一个默认效果,可以阻止对象的移动,但我们可以通过脚本添加自定义行为;然而,使用触发器,除非我们添加自定义行为,否则它不会产生任何明显的效果。无论如何,我们可以为两种可能的场景编写脚本,例如添加分数、减少生命值和输掉游戏。为此,我们可以使用物理事件套件。

这些事件分为两组,碰撞事件触发事件,因此根据你的对象设置,你需要选择适当的组。两组都有三个主要事件,进入持续退出,告诉我们碰撞或触发何时开始(进入),是否仍在发生或仍在接触(持续),以及何时停止接触(退出)。例如,我们可以在进入事件中编写一个行为,例如在两个物体首次接触时播放声音,如摩擦声,并在退出事件中停止播放。

让我们通过创建我们的第一个接触行为来测试这个:子弹在接触物体时被销毁。记住,子弹被配置为触发器,因此它们在与任何物体接触时将生成触发器事件。你可以按照以下步骤操作:

  1. 玩家子弹预制件上创建并添加一个名为 ContactDestroyer 的脚本;由于敌人子弹预制件是其变体,它也将拥有相同的脚本。

  2. 要检测触发器何时发生,例如使用开始更新,创建一个名为 OnTriggerEnter 的事件函数。

  3. 在事件内部,使用 Destroy(gameObject); 行来使子弹在接触物体时销毁自身:

图片

图 7.12:与物体接触时自动销毁

  1. 保存脚本,并将子弹射向墙壁以观察它们是如何消失而不是穿过墙壁的。在这里,我们不是处理碰撞,而是一个触发器,它在接触时销毁子弹。这样,我们可以确保子弹永远不会穿过任何东西,但我们还没有实现基于物理的运动。

在启用这些组件之后,目前我们不需要其他碰撞事件,但如果你需要它们,它们的工作方式将类似;只需创建一个名为 OnCollisionEnter 的函数即可。

现在,让我们探索同一函数的另一个版本。我们将配置它,不仅告诉我们我们击中了什么,而且还告诉我们我们接触了什么。我们将使用这个来使我们的接触销毁器也销毁其他对象。为此,请按照以下步骤操作:

  1. OnTriggerEnter 方法签名替换为以下截图中的那个。这个方法接收一个 Collider 类型的参数,表示撞击我们的确切碰撞体:

图片

图 7.14:告诉我们我们碰撞了哪个对象的触发事件版本

  1. 我们可以使用gameObject属性访问那个 collider 的 GameObject。我们可以使用这个来摧毁另一个对象,如下面的截图所示。如果我们只是通过传递other变量来使用Destroy函数,它将只会摧毁Collider组件:

图 7.14:摧毁两个对象

  1. 保存并测试脚本。你会注意到子弹会摧毁它接触到的所有东西。记得验证你的敌人有一个胶囊碰撞器,这样子弹才能检测到与之的碰撞。

在可视化脚本中的等效版本如下所示:

图 7.15:使用可视化脚本摧毁两个对象

如你所见,我们创建了一个On Trigger Enter节点并将其连接到两个Destroy节点。为了指定每个Destroy节点将摧毁哪个对象,我们使用了Component: Get GameObject节点两次。右边的一个没有节点连接到其左侧输入引脚,这意味着它将返回当前执行此脚本的 GameObject(因此,节点左侧的This标签),在这种情况下,子弹。对于第二个,我们需要将OnTriggerEnter节点右侧的Collider输出引脚连接到Get GameObject节点;这样,我们指定我们想要获取子弹碰撞到的包含 collider 的 GameObject。

现在,在我们的游戏中,我们不希望子弹在接触时摧毁一切;相反,我们将让敌人和玩家拥有生命值;子弹将减少生命值,直到它达到 0,所以让我们找出如何做到这一点。

修改其他对象

为了让子弹损坏碰撞的对象,我们需要访问一个Life组件来更改其数量,所以我们需要创建这个Life组件来保存一个包含生命值的浮点字段。具有此组件的每个对象都将被视为可损坏的对象。要从我们的子弹脚本中访问Life组件,我们需要GetComponent函数。

如果你有一个 GameObject 或组件的引用,你可以使用GetComponent来访问对象中包含的特定组件(如果没有,它将返回null)。让我们看看如何使用这个函数来使子弹减少其他对象的生命值:

  1. 为玩家和敌人 Prefab 创建并添加一个带有public float字段的Life组件,称为amount。记得在检查器中为两者设置值为100(或你想要给他们的任何生命值):

图 7.17:生命组件

  1. 从玩家子弹中移除ContactDestroyer组件,这也会将其从Enemy Bullet Variant中移除。

  2. 为敌人和玩家添加一个新的脚本名为ContactDamager

  3. 添加一个接收other碰撞器作为参数的OnTriggerEnter事件,并仅添加自动销毁自己的Destroy函数调用,而不是销毁其他对象的那个;我们的脚本不会负责销毁它,只是减少其生命值。

  4. 添加一个名为damage的浮点字段,以便我们可以配置对其他对象造成的伤害量。记住在继续之前保存文件并设置一个值。

  5. 使用GetComponent在另一个碰撞器的引用上获取其Life组件的引用,并将其保存在一个变量中:

图片

图 7.18:访问碰撞对象的 Life 组件

  1. 在减少对象的寿命之前,我们必须检查Life引用不是null,这会在其他对象没有Life组件的情况下发生,例如墙壁和障碍物。想法是子弹在遇到任何东西时都会销毁自己,如果它是包含Life组件的可伤害对象,则会减少其他对象的生命值。

    在以下屏幕截图中,您将找到完整的脚本:

    图片

    图 7.18:减少碰撞对象的寿命

  2. 在场景中放置一个敌人,并将其速度设置为0以防止其移动。

  3. 在按播放之前在层次结构中选择它,并开始向它射击。

你可以在检查器中看到生命值是如何减少的。你还可以按Esc键恢复鼠标控制,并在播放模式下选择对象,同时看到编辑器中运行时生命字段的更改。

现在,你会注意到生命正在减少,但它会变成负数;我们希望当生命低于 0 时对象会销毁自己。我们可以通过两种方式做到这一点:一种是在Life组件中添加一个Update,它会检查所有帧以查看生命是否低于 0,并在发生这种情况时销毁自己。

第二种方法是通过封装生命字段并在 setter 中检查其值来防止所有帧都被检查。我更喜欢第二种方法,但我们将实现第一种方法,以使脚本对初学者尽可能简单。

要做到这一点,请按照以下步骤操作:

  1. Update添加到Life组件中。

  2. 添加If以检查数量字段是否小于或等于0

  3. if条件为true的情况下,添加Destroy

  4. 完整的Life脚本将如下截图所示:

图片

图 7.19:Life 组件

  1. 保存并查看当Life变为0时对象是如何被销毁的。

Life组件的视觉脚本版本看起来如下:

图片

图 7.20:视觉脚本中的 Life 组件

脚本相当简单——我们检查我们的Life变量是否小于 0,然后销毁自己,就像我们之前做的那样。现在,让我们检查Damager脚本:

图片

图 7.21:视觉脚本中的 Damager 组件

这个版本与我们的 C#版本略有不同。乍一看,它看起来相同:我们像之前一样使用获取变量来读取生命值,然后使用减去节点从生命中减去伤害,计算结果成为新的生命值,使用设置变量节点来改变该变量的当前值。

我们在这里可以看到的第一个区别是缺少任何GetComponent节点。在 C#中,我们使用该指令来获取碰撞对象的Life组件,以便读取和修改其数量变量,减少剩余的生命值。但在 Visual Scripting 中,我们的节点图没有变量,因此我们不需要访问组件来读取它们。相反,我们知道敌人其变量组件中有一个名为Life的变量,我们使用获取变量节点,将其连接到我们撞击的碰撞器(On Trigger EnterCollider输出引脚),因此本质上,我们正在读取被撞击对象的Life变量值。

同样的方法也适用于更改其值:我们使用设置值节点,将其连接到碰撞器,指定我们想要更改碰撞器对象的Life变量值,而不是我们自己的(因为子弹没有Life变量)。请注意,如果被撞击的对象没有Life变量,这可能会引发错误,这就是为什么我们添加了对象有变量节点,它检查对象是否有一个名为Life的变量。如果没有,我们就什么也不做,这在与墙壁或其他不可破坏的对象碰撞时很有用。最后,我们让伤害者(在这个例子中是子弹)自动销毁自己。

当这种情况发生时,你可以实例化一个对象,比如声音、粒子,或者可能是一个增强道具。我将把这个留给你作为挑战。通过使用类似的脚本,你可以实现一个增加生命值的生命增强道具,或者一个通过访问PlayerMovement脚本并增加速度字段的加速增强道具;从现在开始,发挥你的想象力,使用之前获得的知识来创建令人兴奋的行为。

现在我们已经探讨了如何检测碰撞并对其做出反应,让我们来探讨如何修复玩家在撞击墙壁时掉落的问题。

使用物理移动

到目前为止,玩家是唯一一个使用动态碰撞器配置文件移动的对象,并且将根据物理规则移动,实际上是通过自定义脚本使用 Transform API 来移动的。相反,每个动态对象都应该使用 Rigidbody API 函数以物理系统更易理解的方式移动。因此,在这里,我们将探讨如何移动对象,这次是通过Rigidbody组件。

在本节中,我们将探讨以下物理运动概念:

  • 应用力

  • 调整物理

我们将首先了解如何通过力以正确的方式移动对象,并将此概念应用于玩家的移动。然后,我们将探讨为什么现实中的物理并不总是有趣的,以及我们如何调整对象的物理属性以使其行为更加灵敏和吸引人。

应用力

移动物体的物理准确方式是通过力,这会影响物体的速度。要施加力,我们需要访问Rigidbody而不是Transform,并使用AddForceAddTorque函数分别进行移动和旋转。这些函数允许您指定要施加到位置和旋转每个轴上的力的大小。这种移动方式将具有完整的物理反应;力将累积在速度上以开始移动,并受到阻力效应的影响,这将使速度逐渐减小,而且这里最重要的方面是它们将碰撞到墙壁,阻挡物体的路径。

要获得这种移动,我们可以执行以下操作:

  1. PlayerMovement脚本中创建一个Rigidbody字段,但这次将其设置为private——也就是说,不要在字段中写入public关键字,这将使其在编辑器中消失;我们将以另一种方式获取引用:

图片

图 7.22:私有 Rigidbody 引用字段

  1. 注意,我们之所以将这个变量命名为rb,只是为了防止我们的脚本过于宽泛,使得书中代码的截图太小。建议在您的脚本中正确命名变量——在这种情况下,它应该命名为rigidbody

  2. Start事件函数中使用GetComponent,获取我们的 Rigidbody 并将其保存在字段中。我们将使用此字段来缓存GetComponent函数的结果;每帧调用该函数以访问 Rigidbody 是不高效的。此外,请注意,GetComponent函数不仅可以用来检索其他对象的组件(如碰撞示例),还可以用来检索自己的组件:

图片

图 7.23:缓存 Rigidbody 引用以供将来使用

  1. transform.Translate调用替换为rb.AddRelativeForce。这将调用 Rigidbody 的加力函数,特别是相对的函数,这将考虑对象的当前旋转。例如,如果您在z轴(第三个参数)上指定一个力,则对象将沿着其前进向量施加力。

  2. transform.Rotate调用替换为rb.AddRelativeTorque,这将应用旋转力:

包含文本、截图、字体、行描述的图片,自动生成

图 7.24:使用 Rigidbody 力 API

  1. 检查玩家 GameObject 的胶囊碰撞体是否不与地板相交,并且略微高于地板。如果玩家相交,则移动将无法正常工作。如果是这种情况,请将其向上移动。

如果您之前使用过 Unity,您可能会觉得使用Update而不是FixedUpdate来应用物理力很奇怪。FixedUpdate 是一种特殊的更新,它以固定的速率运行,不受实际游戏每秒帧数FPS)的影响,而物理系统就在这里执行。默认情况下,它被配置为每帧运行 50 次。这意味着如果游戏以 200 FPS 运行,FixedUpdate 将每 4 帧执行一次,但如果游戏以 25 FPS 运行,则固定更新将每帧执行两次。这样做是为了增强物理计算的稳定性,考虑到它们的复杂性。

虽然在 FixedUpdate 中调用任何应用力和扭矩的 Rigidbody 方法可能是正确的,但在 Update 方法中这样做也不一定错误。为了简单起见,我们保留了代码在 Update 方法中,因为 FixedUpdate 对于初学者来说可能难以使用,因为它可能每帧执行多次,甚至跳过一些帧。一个例子是使用Input.GetKeyDown等方法检查按键是否被按下,因为按键压力发生在特定的帧中。如果您在 FixedUpdate 中调用该方法,而 FixedUpdate 跳过了按键被按下的帧,则按键压力将不会被检测到,使得游戏感觉不响应。一个经典的修复方法是使用 Update 方法检测按键压力,并将它们存储在布尔变量中,以便稍后在 FixedUpdate 中检查。但再次强调,由于简单起见,我们决定保持原样。

在 Visual Scripting 版本中,变化是相同的:将TransformRotate节点分别替换为添加相对力添加相对扭矩节点。以下是一个添加相对力的示例:

计算机程序屏幕截图  描述由低置信度自动生成

图 7.25:使用 Rigidbody Forces API

对于这样的旋转:

计算机屏幕截图  描述由中等置信度自动生成

图 7.26:使用 Rigidbody 扭矩 API

您可以看到,在这里我们也不需要使用GetComponent节点,因为仅仅使用添加相对力扭矩节点就足以让 Visual Scripting 理解我们想要在我们的 Rigidbody 组件上应用这些动作(再次解释 This 标签的使用)。如果在任何其他情况下,我们需要在我们的 Rigidbody 组件之外调用这些函数,我们则需要在该处使用GetComponent节点,但让我们稍后再探讨这一点。

现在,如果你保存并测试结果,你可能会发现玩家正在下落,这是因为我们现在使用的是真实物理,其中包含地板摩擦,由于作用力在质心处,这会使物体下落。记住,在物理方面,你是一个胶囊;你没有腿可以移动,这就是标准物理不适合我们的游戏的地方。解决方案是调整物理以模拟我们需要的这种行为。

调整物理

要使我们的玩家像在常规平台游戏一样移动,我们需要冻结某些轴以防止物体下落。移除地面摩擦,并增加空气摩擦(阻力)以使玩家在释放按键时自动减速。

要完成此操作,请按照以下步骤进行:

  1. Rigidbody组件中,查看底部的约束部分,并检查冻结旋转属性的XZ轴:

计算机截图  描述由低置信度自动生成

图 7.27:冻结旋转轴

  1. 这将防止物体向侧面下落,但允许它在水平方向上旋转。如果你不希望玩家跳跃,也可以冻结冻结位置属性的y轴,以防止在碰撞时出现一些不希望的垂直移动。

  2. 你可能需要更改速度值,因为你从每秒米值更改为每秒牛顿值,这是添加力添加扭矩函数的预期值。对我来说,速度为 1,000,旋转速度为 160 就足够了。

  3. 现在,你可能会注意到速度和旋转会随着时间的推移而大幅增加。记住,你正在使用力,这会影响你的速度。当你停止施加力时,速度会被保留,这就是为什么即使你不移动鼠标,玩家也会继续旋转。解决这个问题的方法是增加阻力角阻力,这模拟了空气摩擦,并将分别在无力作用时减少移动和旋转。尝试找到适合你的值;在我的情况下,我使用了2作为阻力10作为角阻力,需要将旋转速度增加到150以补偿阻力的增加:

图 7.28:设置旋转和移动的空气摩擦

  1. 现在,如果你在接触墙壁时移动,而不是像大多数游戏那样滑动,你的玩家会因为接触摩擦而粘附在障碍物上。我们可以通过创建一个Physics Material来移除这一点,这是一个可以分配给碰撞体以控制它们在这些场景中如何反应的资产。

  2. 通过点击项目窗口中的+按钮并选择物理材质(不是 2D 版本)来开始创建一个。将其命名为Player,并记得将其放入一个用于此类资产的文件夹中。

  3. 选择它,并将静摩擦动摩擦设置为0,将摩擦组合设置为最小,这将使物理系统选择两个碰撞物体中的最小摩擦,这总是最小的——在我们的案例中,是零:

图 7.29:创建物理材质

  1. 选择玩家,并将此资产拖动到胶囊碰撞体材质属性:

图 7.30:设置玩家的物理材质

  1. 如果你现在玩游戏,你可能会注意到玩家会比以前移动得更快,因为我们没有在地板上施加任何类型的摩擦,所以你可能需要减少移动力。

正如你所见,我们需要弯曲物理规则以允许玩家有响应性的移动。你可以通过增加阻力和力来获得更多的响应性,这样速度可以更快地应用和减少,但这又取决于你希望游戏拥有的体验。

一些游戏希望得到即时的响应,没有速度插值,从 0 到全速,反之亦然,从一个帧到另一个帧。在这些情况下,你可以直接根据自己的意愿覆盖玩家的速度和旋转向量,甚至可以使用物理以外的其他系统,例如具有专门物理特性的 Character Controller 组件。你可以在这里了解更多:docs.unity3d.com/Manual/CharacterControllers.html

摘要

每个游戏都以某种方式包含物理,无论是为了移动、碰撞检测还是两者兼而有之。在本章中,我们学习了如何使用物理系统,包括了解适当的设置以使系统正常工作,对碰撞做出反应以生成游戏系统,以及以使玩家以物理不准确的方式移动并与其碰撞的方式移动玩家。我们使用这些概念来创建我们的玩家和子弹移动,并使我们的子弹对敌人造成伤害,但我们也可以重用这些知识来创建无数其他可能的游戏需求,所以我建议你在这里的物理概念上稍微玩一玩;你可以发现很多有趣的用例。

在下一章中,我们将讨论如何编程游戏的视觉方面,例如效果,并使 UI 对输入做出反应。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家和作者本人一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything 会议与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

第八章:胜利或失败:胜负条件

现在我们已经有一个基本的游戏体验,是时候让游戏以胜利或失败的结果结束。实现这一点的常见方法是通过分离的组件,这些组件负责监督一组对象以检测需要发生的情况,例如玩家的生命值变为 0 或所有波次都被清除。我们将通过管理器的概念来实现这一点,管理器将管理和监控多个对象。

在本章中,我们将探讨以下管理器概念:

  • 创建对象管理器

  • 创建游戏模式

  • 通过事件改进我们的代码

有了这些知识,你将能够不仅创建游戏的胜负条件,而且还能以正确的方式使用设计模式,如单例(Singleton)和事件监听器(Event Listeners)来创建这些条件。这些技能不仅对创建游戏的胜负代码有用,对任何一般的代码都很有用。首先,让我们从创建代表如得分或游戏规则等概念的管理器开始。

创建对象管理器

并非场景中的每个对象都应该是可以看到、听到或与之碰撞的。一些对象也可以存在概念意义,而不是有形的东西。例如,想象一下你需要计算敌人的数量:你将把它保存在哪里?你还需要一个地方来保存玩家的当前得分,你可能认为它应该在玩家本身上,但如果玩家死亡并重生了呢?

数据将会丢失!在这种情况下,管理器的概念可以是我们早期游戏中解决这一问题的有用方式,所以让我们来探索它。

在本节中,我们将看到以下对象管理器概念:

  • 使用单例设计模式共享变量

  • 在视觉脚本中共享变量

  • 创建管理器

我们将首先讨论单例设计模式是什么以及它如何帮助我们简化对象的通信。有了它,我们将创建管理器对象,使我们能够集中管理一组对象的信息,以及其他方面。让我们先从讨论单例设计模式开始。

使用单例设计模式共享变量

设计模式通常被描述为对常见问题的常见解决方案。在编写游戏代码时,你将不得不做出几个编码设计决策,但幸运的是,处理最常见情况的方法是众所周知的,并且有详细的文档。在本节中,我们将讨论最常见的设计模式之一,单例(Singleton),它在简单项目中易于实现。

当我们需要一个对象的单个实例时,会使用 Singleton 模式,这意味着不应该有超过一个类的实例,并且我们希望它易于访问(虽然不是必需的,但在我们的场景中很有用)。在我们的游戏中有很多情况可以应用 Singleton 模式,例如,ScoreManager,一个将保存当前分数的组件。在这种情况下,我们永远不会有多于一个分数,因此我们可以利用 Singleton 管理器的优势。

一个好处是确保我们不会有重复的分数,这使我们的代码更不容易出错。此外,到目前为止,我们需要创建公共引用并通过编辑器拖动对象来连接两个对象,或者使用GetComponent来查找它们;然而,使用这种模式,我们将能够全局访问我们的 Singleton 组件,这意味着你只需在脚本中写下组件的名称,就可以访问它。最后,只有一个ScoreManager组件,所以通过编辑器指定一个就多余了。这类似于Time.deltaTime,负责管理时间的类——我们只有一个时间。

如果你是一个高级程序员,你现在可能正在考虑代码测试和依赖注入,你是对的,但请记住,到目前为止,我们正在尝试编写简单的代码,所以我们将坚持这个简单的解决方案。

让我们创建一个Score Manager对象,负责处理分数,以下是如何通过以下方式展示 Singleton 的一个例子:

  1. 创建一个空的 GameObject(GameObject | Create Empty),并将其命名为ScoreManager;通常,管理者被放在空对象中,与场景中的其他对象分离。

  2. 向此对象添加一个名为ScoreManager的脚本,并添加一个名为amountint字段,该字段将保存当前分数。

  3. 添加一个名为instanceScoreManager类型字段,但向其添加static关键字;这将使变量全局,意味着只需写出其名称就可以在任何地方访问它:

计算机程序截图,自动生成描述,置信度低

图 8.1:可以在代码的任何地方访问的静态字段

  1. Awake方法中,检查实例字段是否不为null,如果是,则使用this引用将此ScoreManager实例设置为实例引用。

  2. null检查if语句的else子句中,打印一条消息,指出存在一个必须销毁的第二个ScoreManager实例:包含文本、截图、字体、行描述的图片,自动生成

    图 8.2:检查是否存在唯一的 Singleton 实例

    策略是将对唯一的ScoreManager实例的引用保存在instance静态字段中,但如果用户不小心创建了带有ScoreManager组件的两个对象,这个if语句将检测到它,并通知用户错误,要求他们采取行动。在这种情况下,第一个执行AwakeScoreManager实例会发现没有设置实例(字段是null),因此它会将自己设置为当前实例,而第二个ScoreManager实例会发现实例已经设置,并将打印出消息。

    请记住,instance是一个静态字段,在所有类之间共享,与常规引用字段不同,每个组件都将有自己的引用,所以在这种情况下,场景中添加了两个ScoreManager实例,它们将共享相同的实例字段。

    为了稍微改进一下示例,最好有一种简单的方法来找到游戏中第二个ScoreManager。它可能隐藏在层级结构中的某个地方,可能很难找到,但我们可以通过以下方式解决这个问题:

  3. print替换为Debug.LogDebug.Logprint类似,但有一个期望在控制台中点击消息时突出显示对象的第二个参数。在这种情况下,我们将传递gameObject引用,以便控制台突出显示重复的对象:![img/B21361_08_03_PE.png]

    图 8.3:使用 Debug.Log 在控制台中打印消息

    点击日志消息后,包含重复ScoreManager的 GameObject 将在层级结构中高亮显示:

    A screen shot of a computer  Description automatically generated with medium confidence

    图 8.4:点击消息后的高亮对象

  4. 最后,可以通过将Debug.Log替换为Debug.LogError来在这里进行一点小小的改进,这将打印出消息,但带有错误图标。在实际游戏中,控制台将有很多消息,突出显示错误信息而不是信息消息将帮助我们快速识别它们:

![img/B21361_08_05_PE.png]

图 8.5:使用 LogError 打印错误消息

  1. 尝试运行代码并观察控制台中的错误消息:A screenshot of a computer error message  Description automatically generated with low confidence

    图 8.6:控制台中的错误消息

    下一步是在某个地方使用这个单例对象,所以在这种情况下,我们将让敌人被杀死时获得分数,具体操作如下:

  2. 将名为ScoreOnDeath的脚本添加到Enemy预制件中,其中有一个名为amountint字段,该字段将指示敌人被杀死时将获得的分数。请记住,在编辑器中将值设置为除0以外的其他值。

  3. 创建OnDestroy事件函数,当这个对象被销毁时,Unity 会自动调用它——在我们的案例中,是敌人:包含文本、字体、设计的图片  描述由低置信度自动生成

    图 8.7:OnDestroy 事件函数

    考虑到OnDestroy函数在改变场景或游戏退出时也会被调用,所以在这种情况下,我们在改变场景时可能会获得分数,这是不正确的。到目前为止,在我们的案例中这并不是问题,但稍后在本章中,我们将看到一种防止这种情况的方法。

  4. OnDestroy函数中通过编写ScoreManager.instance来访问单例引用,并将我们脚本中的amount字段添加到单例的amount字段中,以便在敌人被杀死时增加分数:

计算机代码的屏幕截图  描述由低置信度自动生成

图 8.8:完整的 ScoreOnDeath 组件类内容

  1. 在层次结构中选择ScoreManager,按播放,然后杀死一些敌人以看到每次击杀分数都会上升。记得设置 Prefab 的ScoreOnDeath组件的amount字段。

正如你所见,单例模式简化了我们访问ScoreManager的方式,并采取了大量安全措施来防止其自身的重复,这将帮助我们减少代码中的错误。需要注意的是,现在你可能会倾向于将一切事物都做成单例,比如玩家的生命或玩家的子弹,以便在创建游戏机制,如增强效果时使你的生活更轻松。

虽然这样完全可行,但请记住,你的游戏将发生很大变化;任何真实的项目都会经历不断的变更。也许今天,游戏只有一个玩家,但将来你可能想添加第二个玩家或一个 AI 伴侣,并且你希望增强效果也能影响他们。如果你过度使用单例模式,你将难以处理这些场景以及更多的情况。也许未来的玩家伴侣会尝试获取生命值恢复,但主玩家却被治愈了!

这里的关键是尽量少地使用这个模式,以防你没有其他解决问题的方法。说实话,总是有不用单例解决问题的方法,但它们对初学者来说实现起来要困难一些,所以我更喜欢简化你的生活,让你保持动力。随着足够的练习,你将达到一个可以准备提高你的编码标准的点。

有许多设计模式可以帮助你设计游戏。一旦你对 Unity 脚本编程感到舒适,我们建议阅读以下 Unity 官方游戏编程模式书籍:resources.unity.com/games/level-up-your-code-with-game-programming-patterns。这本书还包括了单例的高级实现。

现在,让我们讨论如何在视觉脚本中实现这一点,鉴于它会有所不同,因此值得单独成章。如果您对视觉脚本方面不感兴趣,可以考虑跳过以下部分。

使用视觉脚本共享变量

视觉脚本有一个机制,用场景变量替换单例作为在对象之间共享变量的持有者:场景变量。如果您检查脚本图编辑器(我们编辑脚本节点窗口)左侧的面板(显示我们对象变量的面板)下的黑板面板(显示我们对象变量的面板),您会注意到它将有许多标签页:图、对象、场景、应用已保存。如果您看不到黑板面板,请点击窗口左上角从左到右的第三个按钮——位于i(信息)按钮右侧的按钮:

计算机截图,描述自动生成,置信度中等

图 8.9:脚本图中的黑板(变量)编辑器

到目前为止,当我们创建任何对象的变量组件中的变量时,我们实际上是在创建对象变量:属于对象且在该对象的所有视觉脚本之间共享的变量,但这并不是变量可以拥有的唯一作用域。以下是一个剩余作用域的列表:

  • : 只能通过我们当前图访问的变量。其他脚本无法读取或写入该变量。这有助于保存内部状态,例如 C#中的私有变量。

  • 场景: 可以被当前场景中所有对象访问的变量。当我们更改场景时,这些变量就会丢失。

  • 应用: 可以在任何游戏部分随时访问的变量。这有助于将值从一个场景移动到另一个场景。例如,您可以在一个级别中增加分数,并在下一个级别中继续增加,而不是从 0 重新开始分数。

  • 已保存: 价值在游戏运行之间保持的变量。您可以保存持久数据,例如玩家等级或库存以继续任务,或者更简单的事情,例如用户在选项菜单中设置的声音音量(如果您创建了一个)。

在这种情况下,场景作用域是我们想要的,因为我们打算增加的分数将被场景中的多个对象访问(稍后会有更多介绍),我们不希望它在重置级别以再次播放时持续存在;它需要在每个级别的运行和游戏中重新设置为 0。

要创建场景变量,你可以在编辑任何脚本图时,在Script Graph编辑器的Blackboard面板中选择Scene选项卡,或者你也可以使用当你开始编辑任何图时自动创建的Scene Variables GameObject。那个对象是真正持有变量的对象,不得删除。你会注意到它将有一个Variables组件,就像我们之前使用的那样,但它还将有一个Scene Variables组件,表示这些变量是场景变量。

在下面的截图中,你可以看到我们如何简单地添加了score变量到Scene Variables选项卡,使其在我们的任何脚本图中都可以访问。

计算机截图  描述由中等置信度自动生成

图 8.10:将场景变量添加到我们的游戏中

最后,对于增加分数的行为,我们可以在敌人中添加以下图。记住,像往常一样,只保留 C#或 Visual Scripting 版本的脚本,不要两者都保留。

计算机程序截图  描述由低置信度自动生成

图 8.11:当此对象被销毁时增加分数

首先,这个脚本看起来与我们的 C#版本非常相似;我们添加了对象(Object作用域)的s**coreToAdd**变量,然后根据节点添加到整个场景的score变量中。你可以看到的主要区别是,这里我们使用的是On Disable事件而不是OnDestroy。实际上,OnDestroy是正确的选择,但在当前版本的 Visual Scripting 中,存在一个阻止其正常工作的 bug,所以我暂时替换了它。On Disable的问题在于它会在对象被禁用时执行,而对象在被销毁之前可能已经被禁用,也可能在其他情况下被禁用(例如,使用Object Pooling,这是一种回收对象而不是不断销毁和实例化对象的方法),但到目前为止这已经足够我们使用了。请在尝试此图时首先考虑使用OnDestroy,看看它是否在你的 Unity 或 Visual Scripting 包版本中运行正常。

值得注意的是,使用Has Variable节点来检查分数变量是否存在。这样做是因为OnDisable可以在敌人被销毁或场景改变时执行,我们将在本章后面的内容中通过输赢屏幕来实现这一点。如果我们试图在那个时刻获取场景变量,如果Scene Variables对象在GameMode对象之前被销毁,由于场景的改变涉及首先销毁场景中的所有对象,我们可能会遇到错误。

如您现在可能已经注意到的,尽管视觉脚本与 C# 非常相似,但有一种概念可以解决另一种无法解决的问题。现在我们知道了如何共享变量,让我们完成一些我们将在游戏后期需要的其他管理器。

创建管理器

有时,我们需要一个地方来汇总一组相似对象的信息,例如,EnemyManager,以检查敌人的数量,并可能访问它们的数组以迭代它们并执行任何额外的游戏实现,例如使我们的 MissionManager 能够访问游戏中所有活动的任务。同样,这些情况可以被认为是 Singleton,即不会重复的单个对象(在我们的当前游戏设计中),因此让我们创建我们将在游戏中需要的那些,即 EnemyManagerWaveManager

在我们的游戏中,EnemyManagerWaveManager 将仅用作保存现有敌人波次的引用数组的场所,就像知道它们当前数量的方式一样。有方法可以搜索特定类型的所有对象来计算它们的数量,但这些函数成本高昂,除非你真的知道你在做什么,否则不建议使用。因此,将管理器实现为 Singleton,并具有针对目标对象类型的单独更新引用列表,将需要更多的代码,但性能会更好。此外,随着游戏功能的增加,这些管理器将具有更多功能和支持函数来与这些对象交互。

让我们从敌人管理器开始,按照以下步骤进行:

  1. 将一个名为 Enemy 的脚本添加到 Enemy 预制件中;这将是一个将此对象与 EnemyManager 连接的脚本。

  2. 创建一个名为 EnemyManager 的空 GameObject,并向其添加一个名为 EnemiesManager 的脚本。

  3. 在脚本内部创建一个名为 instanceEnemiesManager 类型的 public 静态字段,并在 Awake 方法中添加与我们在 ScoreManager 中所做的相同的 Singleton 重复检查。

  4. 创建一个名为 enemiesList<Enemy> 类型的公共字段:

    图 8.12:敌人组件列表

    C# 中的列表表示一个动态数组,一个能够添加和删除对象的数组。您将看到您可以在编辑器中向此列表添加和删除元素,但请保持列表为空;我们将以另一种方式添加敌人。请注意,ListSystem.Collections.Generic 命名空间中;您将在我们脚本的开始处找到 using 语句。此外,请考虑您可以将列表设为私有,并通过 getter 而不是公共字段将其暴露给代码;但像往常一样,我们现在将使代码尽可能简单:

    包含文本、字体、白色、截图的图片,自动生成描述

    图 8.13:使用 List 类,位于 System.Collections.Generic

    考虑到 List 是一个类类型,所以它必须被实例化,但因为这个类型在编辑器中有暴露支持,Unity 会自动实例化它。当你想要一个非编辑器暴露的列表时,例如一个私有列表或在常规的非组件 C# 类中的列表,你必须使用 new 关键字来实例化它。

    C# 列表在内部实现为一个数组。如果你需要一个链表,请使用 LinkedList 集合类型代替。

  5. Enemy 脚本的 Start 函数中,访问 EnemyManager 单例,并使用敌人列表的 Add 函数将此对象添加到列表中。这将“注册”此敌人作为活动状态在管理器中,以便其他对象可以访问管理器并检查当前敌人。Start 函数在所有 Awake 函数调用之后被调用,这是很重要的,因为我们需要确保在敌人的 Start 函数之前执行管理器的 Awake 函数,以确保有一个设置为实例的管理器。

    我们使用 Start 函数解决的问题被称为竞态条件,即当两段代码不能保证按相同顺序执行时,而 Awake 执行顺序可能会因不同原因而改变。代码中有很多情况会发生这种情况,所以请注意你代码中可能存在的竞态条件。此外,你可能考虑使用更高级的解决方案,例如在这里使用懒初始化,这可以给你更好的稳定性,但再次强调,为了简单和探索 Unity API,我们目前将使用 Start 函数方法。

  6. OnDestroy 函数中,从列表中删除敌人以保持列表只更新活动对象:

计算机程序的屏幕截图  描述自动生成,置信度低

图 8.14:将自身注册为活动敌人的敌人脚本

这样,我们现在有一个集中的地方可以以简单而有效的方式访问所有活动敌人。我挑战你使用 WaveManager 做同样的事情,它将包含所有活动波浪的集合,以便稍后检查是否所有波浪都完成了它们的工作,从而认为游戏胜利。

花些时间解决这个问题;你将在以下屏幕截图中发现解决方案,从 WavesManager 开始:

计算机程序的截图  描述自动生成,置信度中等

图 8.15:完整的 WavesManager 脚本

你还需要 WaveSpawner 脚本:

计算机代码的截图  描述自动生成,置信度中等

图 8.16:修改后的 WaveSpawner 脚本以支持 WavesManager

如你所见,WavesManager的创建方式与EnemyManager相同——只是一个带有WaveSpawner引用列表的单例,但WaveSpawner是不同的。我们在WaveSpawnerStart事件中执行列表的Add函数,将波注册为活动波,但Remove函数需要更多的工作。

策略是在生成器完成其工作后,从活动波列表中注销该波。在这次修改之前,我们使用Invoke在一段时间后调用CancelInvoke函数来停止生成,但现在我们需要在结束时间之后做更多的事情。

在指定的波结束时间之后,我们不会调用CancelInvoke,而是调用一个名为EndSpawner的自定义函数,该函数将调用CancelInvoke来停止生成器,Invoke Repeating,同时还会调用从WavesManager列表中移除函数,以确保在WaveSpawner完成其工作后,移除列表中的函数被准确调用。

关于视觉脚本版本,我们可以将两个 GameObject 类型的列表添加到场景变量中,以保存现有波和敌人的引用,这样我们就可以跟踪它们。只需在变量类型选择器的搜索栏中搜索List of GameObject,你就可以找到它。在这种情况下,由于WaveSpawner和敌人脚本的视觉脚本版本不是我们可以像 C#那样引用的类型,所以列表中只包含 GameObject。如果你同时创建了这些的 C#和视觉脚本版本,你会看到你可以引用 C#版本,但我们不会混合 C#和视觉脚本,因为这不属于本书的范围,所以忽略它们。无论如何,鉴于视觉脚本变量系统的工作方式,如果需要,我们仍然可以使用GetVariable节点访问变量——记住变量不在视觉脚本中,而是在变量节点中:

计算机屏幕截图,描述自动生成,置信度中等

图 8.17:向场景变量中添加列表

然后,我们可以在WaveSpawner图中添加以下内容:

包含文本、截图、字体、图表的图片,描述自动生成

图 8.18:向列表中添加元素

我们使用Add List Item节点将我们的 GameObject 添加到waves变量中。我们在On Start事件节点之前将其作为第一件事来做。要移除该波从活动波中,你需要进行以下更改:

计算机程序屏幕截图,描述自动生成,置信度低

图 8.19:从列表中移除元素

我们使用For LoopExit流程输出引脚从列表中移除这个生成器,该引脚在for循环完成迭代时执行。

最后,关于敌人,你需要创建一个新的敌人脚本图,它看起来类似:

计算机程序截图  描述由低置信度自动生成

图 8.20:敌人将自己添加到列表中并从中移除

正如你所见,我们只需在On Start时添加敌人,并在OnDisable时移除它。记得由于我们之前提到的错误,首先尝试使用OnDestroy而不是OnDisable。你可以通过在具有Scene Variables GameObject 被选中的情况下玩游戏来检查这些更改,并观察其值如何变化。此外,记得如果我们正在更改场景,需要使用Has Variable节点。

使用对象管理器,我们现在可以集中管理一组对象的信息,并且可以在这里添加各种对象组逻辑。我们创建了EnemiesManagerWavesManagerScoreManager作为集中存储几个游戏系统信息的地方,例如场景中存在的敌人和波次,以及分数。我们还看到了视觉脚本版本,将数据集中存储在场景变量对象中,这样所有的视觉脚本都可以读取这些数据。但除了用于更新 UI(我们将在下一章中这样做)的信息之外,我们还可以使用这些信息来检测我们的游戏是否满足胜利和失败条件,创建一个游戏模式对象来检测这一点。

创建游戏模式

我们已经创建了对象来模拟游戏中的许多游戏玩法方面,但游戏需要结束,无论我们赢或输。一如既往,问题是逻辑应该放在哪里,这引出了更多的问题。主要问题将是,我们是否总是以相同的方式赢或输?我们是否会有一个具有不同于“消灭所有波次”的不同标准的特殊关卡,比如限时生存?只有你知道这些问题的答案,但如果你现在的答案是“不”,这并不意味着它不会在以后改变,因此建议我们准备代码以无缝适应变化。

说实话,编写能够无缝适应变化的代码几乎是不可能的;没有一种方法可以考虑到所有可能的情况,我们迟早都需要重写一些代码。我们将尽力使代码尽可能通用,以适应变化,但我们需要在必要性和不必要的适应性之间找到平衡。创建通用代码往往会生成复杂的代码库,并且需要更多的时间,尽管一定程度的复杂性肯定是必要的,但很多时候,我看到程序员们超出了所需,花费大量时间来解决简单的情况,创建了最终利用率低下的工具。

要做到这一点,我们将胜利和失败条件的逻辑分离到其自己的对象中,我喜欢将其称为“GameMode”(不一定是行业标准)。这将是一个将监督游戏、检查需要满足的条件以考虑游戏结束的组件。它将像我们游戏中的裁判。游戏模式将不断检查对象管理器中的信息,以及可能的其他信息源,以检测所需条件。将此对象与其他对象分离,使我们能够创建具有不同游戏模式的级别;只需在该级别使用另一个游戏模式脚本即可。

在我们的案例中,我们现在将只有一个游戏模式,该模式将检查波浪和敌人的数量是否变为0,这意味着我们已经杀死了所有可能的敌人,游戏胜利。同时,它还将检查玩家的生命值是否达到0,考虑到在这种情况下游戏失败。让我们通过以下步骤创建它:

  1. 创建一个新的空GameMode对象,并向其添加一个WavesGameMode脚本。正如你所见,我们给脚本起了一个描述性的名字,考虑到我们可以添加其他游戏模式。

  2. 在其Update函数中,通过使用EnemyWave管理器检查敌人数量和波浪数量是否达到0;在这种情况下,目前只需在控制台print一条消息。所有列表都有一个Count属性,它将告诉你存储在内部元素的数量。

  3. 添加一个名为PlayerLifeLife类型的public字段,并将玩家拖到该字段;这里的想法是也要在这里检测失败条件。

  4. Update中,添加另一个检查以检测playerLife引用的生命值是否达到0,如果是这样,则在控制台print一条失败信息:

计算机程序截图,描述由低置信度自动生成

图 8.21:WavesGameMode 中的胜利和失败条件检查

  1. 玩游戏并测试两种情况——玩家的生命值是否达到0,或者你是否杀死了所有敌人和波浪。

    现在,是时候用更有趣的消息替换它们了。目前,我们只需将当前场景更改为胜利场景失败场景,该场景将只有一个带有胜利或失败消息和重新播放按钮的 UI。将来,你可以添加一个主菜单****场景,并有一个选项返回到它。让我们通过以下步骤实现这一点:

  2. 创建一个新的场景(文件 | 新场景)并保存它,将其命名为WinScreen

  3. 添加一些指示这是胜利场景的东西,比如简单地用一个球体,相机指向它。这样,我们知道何时切换到胜利场景。

  4. 项目视图中选择场景,然后按Ctrl + D(在 Mac 上为Cmd + D)来复制场景。将其重命名为LoseScreen

  5. 双击LoseScreen场景以打开它,并将球体更改为不同的东西,比如一个立方体。

  6. 前往文件 | 构建设置以打开此窗口内的构建中的场景列表。

这个想法是 Unity 需要你明确声明所有必须包含在游戏中的场景。你可能有一些测试场景或者你还不打算发布的场景,这就是为什么我们需要这样做。在我们的案例中,我们的游戏将包含WinScreenLoseScreen以及我们迄今为止创建的带有游戏场景的场景,我将其称为Game,所以只需将这些场景从项目视图拖到构建设置窗口的列表中;我们需要这样做才能使游戏模式脚本在场景之间正确切换。此外,考虑到这个列表中的第一个场景将是我们在最终版本(即构建版本)中玩游戏时首先打开的场景,所以你可能需要根据这一点重新排列列表:

计算机截图  描述由中等置信度自动生成

图 8.22:将场景注册到游戏构建中

  • WavesGameMode中,添加一个using语句用于UnityEngine.SceneManagement命名空间,以启用此脚本中的场景切换功能。

  • 将控制台print消息替换为对SceneManager.LoadScene函数的调用,该函数将接收一个包含要加载的场景名称的字符串;在这种情况下,将是WinScreenLoseScreen。你只需要场景名称,而不需要文件的完整路径。

    如果你想要连接不同的关卡,你可以创建一个public字符串字段,以便你可以在编辑器中指定要加载哪些场景。记住将场景添加到构建设置中。如果不这样做,当你尝试更改场景时,控制台将显示错误信息:

    计算机程序截图  描述由中等置信度自动生成

    图 8.23:使用 SceneManager 切换场景

  • 玩游戏并检查场景是否正确切换。

目前,我们选择了最简单的方式来显示我们输赢,但将来,你可能想要比突然切换场景更柔和的方式,比如可能使用Invoke等待几秒钟来延迟这个变化,或者直接在游戏中显示胜利信息而不切换场景。当测试游戏并检查人们在玩游戏时是否理解发生了什么时,请记住这一点——游戏反馈对于让玩家了解正在发生的事情非常重要,并且不是一项容易的任务。

关于视觉脚本版本,我们在一个单独的对象中添加了一个新的脚本图。让我们逐个检查它,以便清楚地看到它。让我们从胜利条件开始:

计算机程序截图  描述由低置信度自动生成

图 8.24:视觉脚本中的胜利条件

在这里,我们从场景上下文中获取敌人列表(获取变量节点),并且知道它包含一个列表,因此我们使用计数项节点来检查这个列表中剩余多少敌人。记住,我们有一个脚本在敌人被生成时将其添加到列表中,在它被销毁时将其移除。我们对波次也做同样的处理,所以使用节点将条件组合起来,并通过如果节点连接,然后执行某些操作(稍后详细介绍)。

现在让我们来检查失败条件:

计算机程序截图,描述由低置信度自动生成

图 8.25:视觉脚本中的失败条件

由于玩家的生命状态不在场景上下文中(而且不应该在),并且玩家是一个与名为GameMode的 GameObject 不同的 GameObject(我们专门为这个脚本创建的),我们需要一个类型为 GameObject 的变量名为Player来引用它。

如您所见,我们在变量组件中将我们的玩家拖拽到上面。最后,我们使用获取变量来访问图中的玩家引用,然后使用另一个获取变量来提取其生命值。我们通过将玩家引用连接到获取变量节点的生命变量来实现这一点。然后我们对玩家的基础重复这一过程。

最后,我们通过以下方式加载场景:

计算机程序截图,描述由低置信度自动生成

图 8.26:在视觉脚本中加载场景

如您所见,我们使用场景管理器加载场景(场景名称)节点来加载场景。注意我们加载了我们之前创建的两个场景(WinSceneLoseScene)。记住,这两个场景需要像之前一样添加到构建设置中的构建中的场景部分,以便在这里可以被场景管理器加载。

现在我们有一个功能齐全的简单游戏,具有机制和胜负条件,虽然这足以开始开发我们游戏的其它方面,但我想要讨论一下我们当前管理方法的问题以及如何通过事件来解决这些问题。

使用事件改进我们的代码

到目前为止,我们使用 Unity 事件函数来检测游戏中可能发生的情况,例如AwakeUpdate。Unity 还使用其他类似函数来允许组件之间相互通信,例如OnTriggerEnter,这是 Rigidbody 组件通知 GameObject 中其他组件发生碰撞的一种方式。在我们的情况下,我们使用Update方法中的if语句来检测其他组件的变化,例如GameMode检查敌人数量是否达到 0。但如果我们从敌人管理器那里得知有变化,我们就可以在那个时刻进行检查,例如,使用 Rigidbody 组件告诉我们何时发生碰撞,而不是每帧检查碰撞。

此外,有时我们依赖于 Unity 事件来执行逻辑,例如在OnDestroy事件中给出的分数,它通知我们对象何时被销毁,但由于事件的本性,它可能在我们不希望添加到分数的情况中被调用,例如当场景改变或游戏关闭时。在这些情况下,对象会被销毁,但不是因为玩家杀死了敌人,导致在不应该增加分数的情况下分数增加。在这种情况下,有一个告诉我们生命值达到 0 的事件来执行这个逻辑会很好,而不是依赖于通用的OnDestroy事件。

事件的概念是为了改进我们对象之间的通信模型,确保在某个事件发生的确切时刻,该情境中的相关部分被通知并相应地做出反应。Unity 有很多事件,但我们可以创建针对我们游戏逻辑特定的事件。让我们从应用我们之前讨论过的得分场景开始;想法是让Life组件有一个事件来通知其他组件对象被销毁是因为生命值达到了 0。

有几种方法可以实现这一点,我们将使用与AwakeUpdate方法略有不同的方法;我们将使用UnityEvent字段类型。这是一种字段类型,可以存储在需要执行时将要执行的函数的引用,就像 C#委托一样,但具有其他好处,例如更好的 Unity 编辑器集成。

要实现这一点,请执行以下操作:

  1. Life组件中,创建一个名为onDeathpublic类型的UnityEvent字段。该字段将代表一个事件,其他类可以订阅它,以便在Life达到 0 时得到通知:

包含文本、字体、截图、行描述的图片,描述自动生成

图 8.27:创建自定义事件字段

  1. 如果您保存脚本并进入编辑器,您可以在检查器中看到事件。Unity 事件支持在编辑器中订阅方法,因此我们可以将两个对象连接起来。我们将在 UI 脚本章节中使用这个功能,所以现在就忽略它吧:计算机截图  描述由中等置信度自动生成

    图 8.28:UnityEvents 在检查器中显示

    您可以使用通用委托操作或自定义委托来创建事件,而不是使用UnityEvent,除了某些性能方面外,唯一明显的区别是UnityEvent将在编辑器中显示,如步骤 2所示。

  2. 当生命值达到0时,调用事件的Invoke函数。这样,我们将告诉任何对事件感兴趣的脚本它已经发生:

计算机代码的截图  描述由中等置信度自动生成

图 8.29:执行事件

  1. ScoreOnDeath中,将OnDestroy函数重命名为GivePoints或您喜欢的任何名称;这里的想法是在OnDestroy事件中停止给予分数。

  2. ScoreOnDeath脚本的Awake函数中,使用GetComponent获取Life组件并将其保存在局部变量中。

  3. 调用Life引用的onDeath字段的AddListener函数,并将GivePoints函数作为第一个参数传递。这被称为订阅我们的listener方法GivePointsonDeath事件。想法是告诉LifeonDeath事件被调用时执行GivePoints。这样,Life会通知我们这种情况。记住,您不需要调用GivePoints,只需将其作为字段传递即可:包含文本、截图、字体、行的图片  描述由自动生成

    图 8.30:订阅 OnDeath 事件以在该场景中给予分数

    考虑在OnDestroy中调用RemoveListener;像往常一样,在可能的情况下取消订阅监听器以防止任何内存泄漏(当引用阻止 GC 释放内存时)。在这种情况下,这并不是完全必要的,因为LifeScoreOnDeath组件将同时被销毁,但尽量养成这种良好习惯。

  4. 保存,在编辑器中选择ScoreManager,然后按播放来测试这个功能。在播放模式下尝试从层次结构中删除一个敌人,以检查分数是否没有上升,因为敌人被销毁的原因不是他们的生命值变为 0;您必须射击敌人以看到分数增加。

    现在,由于Life有一个onDeath事件,我们也可以通过以下方式将玩家的Life检查从WavesGameMode替换为使用事件:

  5. WavesGameMode脚本上创建一个OnPlayerDied函数,并将LoseScreen场景的加载从Update方法移动到这个函数。由于事件版本将替代它,你将移除Update方法中检查生命的if语句。

  6. Awake中,将这个新函数添加到玩家Life组件引用的onDeath事件中,在我们的脚本中称为playerLife

计算机程序屏幕截图,描述由低置信度自动生成

图 8.31:使用事件检查失败条件

如你所见,创建自定义事件允许你检测 Unity 中除了默认情况之外更具体的情况,并保持你的代码整洁,无需在Update函数中不断检查几个条件,这并不一定是坏事,但事件方法生成的代码更清晰。

记住,我们也可以通过玩家的基础Life达到 0 来输掉游戏,所以让我们创建一个代表敌人将攻击以减少基础Life的对象的立方体。考虑到这一点,我挑战你将这个第二个失败条件(玩家的基础生命达到 0)添加到我们的脚本中。当你完成时,你可以在下面的屏幕截图中检查解决方案:

计算机程序屏幕截图,描述由低置信度自动生成

图 8.32:完整的 WavesGameMode 失败条件

如你所见,我们只是重复了life事件订阅,记得创建一个对象来表示玩家的基础伤害点,并给它添加一个Life脚本,然后将它拖动到WavesGameMode中的玩家基础Life参考。这里有趣的是,我们为玩家Life和基础LifeonDeath事件订阅了同一个名为OnPlayerOrBaseDied的函数,因为我们希望在这两种情况下得到相同的结果。

现在,让我们继续通过将其应用于经理来阐述这个概念,以防止游戏模式每帧都检查条件:

  1. EnemyManager中添加一个名为onChangedUnityEvent字段。这个事件将在每次将敌人添加到列表或从列表中删除时执行。

  2. 创建两个函数,AddEnemyRemoveEnemy,都接收一个Enemy类型的参数。想法是,而不是让Enemy直接从列表中添加和删除自己,它应该使用这些函数。

  3. 在这两个函数内部,调用onChanged事件来通知其他人敌人列表已被更新。想法是,任何想要从列表中添加或删除敌人的人都需要使用这些函数:计算机代码屏幕截图,描述由中等置信度自动生成

    图 8.33:在添加或删除敌人时调用事件

    在这里,我们面临的问题是没有任何东西阻止我们绕过这两个函数并直接使用列表。你可以通过将列表设为私有并使用IReadOnlyList接口暴露它来解决这个问题。记住,这样,列表在编辑器中不可见,便于调试。

  4. Enemy脚本更改为使用这些函数:

计算机程序屏幕截图,描述自动生成,低置信度

图 8.34:使敌人使用添加和删除函数

  1. WaveManagerWaveSpawner重复相同的步骤,创建一个onChanged事件,创建AddWaveRemoveWave函数,并在WaveSpawner中调用它们而不是直接访问列表。这样,我们可以确保在必要时调用事件,就像我们对EnemyManager所做的那样。

    尝试自己解决这个问题,然后检查以下屏幕截图中的解决方案,从WavesManager开始:

    计算机程序截图,描述自动生成,中等置信度

    图 8.35:WavesManager OnChanged 事件实现

  2. 此外,WaveSpawner还需要以下更改:

计算机程序截图,描述自动生成,中等置信度

图 8.36:实现 AddWave 和 RemoveWave 函数

  1. WavesGameMode中,将Update重命名为CheckWinCondition,并将此函数订阅到EnemyManagerWavesManageronChanged事件。目的是仅在必要时检查敌人数量和波浪的变化。由于单例在Awake中初始化,请记住在Start函数中进行事件订阅:

计算机程序屏幕截图,描述自动生成,低置信度

图 8.37:当敌人或波浪数量变化时检查胜利条件

关于视觉脚本版本,让我们首先通过事件检查失败条件,首先检查生命脚本图中需要的一些更改:

图 8.38:在我们的 Life 脚本图中触发自定义事件

首先,当生命值降至 0 时销毁对象后,我们使用触发自定义事件节点,指定事件名称为OnDeath。这将通知任何等待OnDeath事件执行的人它已经执行。记住,这是我们生命脚本图。确保在触发事件后调用destroy——虽然大多数时候顺序并不重要,因为destroy动作实际上是在帧末发生的,有时可能会引起问题,所以这里还是小心为妙。在这种情况下,游戏模式应该监听玩家的OnDeath事件,所以让我们在我们的游戏模式图中进行以下更改:

图 8.39:在视觉脚本中监听玩家的 OnDeath 事件

我们使用了自定义事件节点,将其连接到我们的游戏模式的玩家引用。这样,我们指定如果那个玩家执行该事件,我们将执行加载场景节点。记住,玩家引用是至关重要的,因为它指定了我们想要从谁那里执行OnDeath事件,记住生命视觉图表也会出现在敌人中,而我们对此不感兴趣。此外,记得移除我们之前用来检测此情况的If节点和条件节点——我们的游戏模式将拥有的唯一If是胜利条件。

实际上,我们让任何使用Life脚本的物体都拥有一个OnDeath事件,并且让游戏模式专门监听玩家的OnDeath事件。

我们也可以为敌人和波次创建事件,但这可能会使我们的图表变得有些复杂,因为我们没有在视觉脚本版本中的WaveManagerEnemyManager。我们当然可以创建这些来完成任务,但有时使用视觉脚本的目的就是创建简单的逻辑,这类改变往往会使得图表变得相当庞大。

另一个可能的解决方案是让敌人和波次直接通知游戏模式。我们可以在敌人和波次中使用触发自定义事件,将其节点连接到游戏模式,最终让游戏模式拥有一个自定义事件节点,从而可以监听。问题是这会违反我们对象之间的正确依赖关系;较低级别的对象,如敌人和波次,不应该与较高级别的对象,如游戏模式,进行通信。

实际上,游戏模式原本应该是一个监管者。如果我们应用前一段描述的解决方案,我们就不可能在另一个场景或游戏中没有游戏模式的情况下拥有敌人。所以,为了简单和代码解耦的目的,让我们保持其他条件不变——更复杂的逻辑可能完全在 C#的完整生产项目中处理。

是的,使用事件意味着我们不得不比以前写更多的代码,从功能上来说,我们没有获得任何新的东西,但在更大的项目中,通过Update检查来管理条件会导致之前讨论过的一些问题,例如竞争条件和性能问题。有时,拥有可扩展的代码库需要更多的代码,而这正是其中之一。

在我们完成之前,有一点需要考虑的是,Unity 事件并不是在 Unity 中创建此类事件通信的唯一方式;你将发现一个类似的方法称为Action,这是事件的本地 C#版本,如果你想要查看所有可用的选项,我建议你了解一下。

在本章中,我们探索了一些编程模式,但还有很多。你可以在这里了解更多:gameprogrammingpatterns.com/

摘要

在本章中,我们完成了游戏的一个重要部分:结局,无论是通过胜利还是失败。我们讨论了一种简单但强大的方法,通过使用单例创建的管理器来分离不同的责任层,以确保每种类型的管理器只有一个实例,并通过静态访问简化它们之间的连接。此外,我们还探讨了事件的概念,以简化对象之间的通信,防止问题并创建更有意义的对象间通信。

借助这些知识,你现在不仅能够检测游戏的胜利和失败条件,而且还能以更结构化的方式进行。这些模式可以用来改进我们的游戏代码,我建议你尝试在其他相关场景中应用它们。

在下一章中,我们将开始书的第三部分提升视觉效果、效果和音频,我们将探讨不同的 Unity 系统来改善我们游戏的图形和音频方面,首先看看我们如何创建材料来修改我们对象的一些属性,以及如何使用 Shader Graph 创建着色器。

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything(问我任何问题)环节与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第九章:开始你的 AI 之旅:为你的游戏构建智能敌人

如果说游戏不是对玩家的一项巨大挑战,那么玩家需要使用角色的能力来应对不同的场景,那游戏又是什么呢?每种游戏都会给玩家带来不同类型的障碍,但在我们的游戏中,主要的挑战来自敌人。想象一下,一个能够感知你的存在并战略性地计划攻击的敌人——这正是我们通过这些 AI 技术所追求的目标。创建具有挑战性和可信度的敌人可能很复杂;它们必须表现得像真实的人物,足够聪明以提供挑战,但又不足以不可战胜。我们将使用基本但足够的 AI 技术来使 AI 能够感知其周围环境,并根据这些信息做出决策,决定做什么,使用有限状态机FSMs)以及其他技术。这些决策将通过智能路径查找来执行。

在本章中,我们将探讨以下 AI 概念:

  • 使用传感器收集信息

  • 使用 FSM 进行决策

  • 执行 FSM 动作

这些组件对于创建不仅具有反应性,而且展现出类似智能和策略的敌人至关重要。到本章结束时,你将拥有一个功能齐全的敌人,能够检测玩家并对其进行攻击,所以让我们首先看看如何制作传感器系统。

使用传感器收集信息

游戏中的 AI 工作在三个步骤中:收集信息、分析信息,并根据这种分析执行动作。这一点很重要,因为它反映了人类思维的一个简单版本,使得生成的 AI 更加逼真。正如你所见,没有信息我们无法做任何事情,所以让我们从这部分开始。

我们的人工智能可以使用多种信息来源,例如关于自身的数据(生命和子弹)或可能是某些游戏状态(胜利条件或剩余敌人),这些都可以通过我们迄今为止看到的代码轻松找到。然而,一个重要的信息来源是传感器,如视觉和听觉。在我们的案例中,视觉就足够了,所以让我们学习如何编写这部分代码。

在本节中,我们将探讨以下传感器概念:

  • 使用 C#创建三种过滤传感器

  • 使用视觉脚本创建三种过滤传感器

  • 使用 gizmos 进行调试

让我们先看看如何使用三种过滤方法创建一个传感器。

使用 C#创建三种过滤传感器

编码感官的常见方法是使用一种三种过滤方法来排除视线之外的敌人。每个过滤器都会排除超出我们传感器范围的物体。第一个过滤器是距离过滤器,它会排除太远而看不见的敌人,然后第二个过滤器是角度检查,它会检查我们视野锥体内的敌人,最后,第三个过滤器是射线投射检查,它会排除被墙壁等障碍物遮挡的敌人。

在开始之前,有一句话要提醒:我们将在这里使用向量数学,而深入探讨这些主题超出了本书的范围。如果您不理解某些内容,请随时在网上搜索截图中的代码。

让我们以下面的方式编写传感器代码:

  1. 创建一个名为 AI 的空 GameObject,作为 Enemy 预制件的子对象。您需要首先打开预制件以修改其子对象(双击预制件)。请记住将此 GameObject 的变换设置为 位置0, 1.75,0),旋转(0,0,0)和 缩放(1,1,1),以便它与敌人的眼睛对齐。这样做是为了未来我们将做的视觉传感器。请注意,您的 Enemy 预制件的眼睛可能具有不同的高度。虽然我们可以直接将所有 AI 脚本直接放在 Enemy 预制件根 GameObject 中,但我们这样做只是为了分离和组织:

图 9.1:AI 脚本容器

  1. 创建一个名为 Sight 的脚本,并将其添加到 AI 子 GameObject。

  2. 创建两个名为 distanceanglefloat 类型的字段,以及另外两个名为 obstaclesLayersobjectsLayersLayerMask 类型的字段。distance 字段将用作视野距离,angle 将确定视锥体的幅度,obstaclesLayers 将用于我们的障碍物检查以确定哪些对象被认为是障碍物,而 objectsLayers 将用于确定我们想要 Sight 组件检测的对象类型。

    我们只想让视野看到敌人;我们对墙壁或增益物品等对象不感兴趣。LayerMask 是一种属性类型,允许我们在代码中选择一个或多个层来使用,因此我们将通过层过滤对象。

图 9.2:用于参数化我们的视野检查的字段

  1. Update 中调用 Physics.OverlapSphere,如 图 9.3 所示。

此函数在由第一个参数指定的位置(在我们的情况下,是我们的位置)创建一个假想的球体,并具有第二个参数指定的半径(距离属性)以检测具有第三个参数指定的层(ObjectsLayers)的对象。它将返回一个包含在球体内找到的所有碰撞器的数组;这些函数使用物理进行检查,因此对象必须至少有一个碰撞器。

这是我们将使用的方法来找到我们视野距离内的所有敌人,我们将在下一步中进一步过滤它们。请注意,我们正在将我们的位置传递给第一个参数,这实际上不是敌人的位置,而是 AI 子对象的位置,因为我们的脚本位于那里。这突出了 AI 对象位置的重要性。

完成第一个检查的另一种方法是检查我们想要看到的物体与玩家之间的距离,或者如果寻找其他类型的物体,到一个包含它们的Manager组件。然而,我们选择的方法更灵活,可以用于任何类型的物体。

此外,你可能还想检查这个函数的Physics.OverlapSphereNonAlloc版本,它执行相同的操作,但通过不分配返回结果的数组而更高效。

  1. 使用for循环遍历函数返回的对象数组:图片

    图 9.3:获取一定距离内的所有 GameObject

    为了检测物体是否落在视野锥内,我们需要计算我们的观察方向与从我们自身指向物体本身的方位之间的角度。如果这两个方向之间的角度小于我们的锥形角度,我们认为物体落在我们的视野内。我们将在以下步骤中这样做:

  2. 开始计算指向物体的方向,这可以通过将物体位置与我们的位置之间的差异进行归一化来实现,如图 9.4所示。你可能注意到我们使用了bounds.center而不是transform.position;这样,我们检查的是指向物体中心的方位,而不是其旋转点。记住,玩家的旋转点在地面,射线检查可能会在玩家之前与之碰撞:

图片

图 9.4:从我们的位置计算指向碰撞体的方向

  1. 我们可以使用Vector3.Angle函数来计算两个方向之间的角度。在我们的情况下,我们可以计算指向敌人的方向与我们的前进向量之间的角度来查看角度:图片

    图 9.5:计算两个方向之间的角度

    如果你愿意,你可以使用Vector3.Dot,这将执行点积——一种数学函数,用于计算一个向量在另一个向量上的投影长度(在网上搜索更多信息)。Vector3.Angle实际上使用这个函数,但将点积的结果转换为角度,这需要使用三角函数,并且可能计算起来很耗时。我们的Vector3.Angle方法更简单,编码更快,而且鉴于我们不需要很多传感器,因为我们不会有太多敌人,使用点积优化传感器现在不是必要的,但请考虑这一点,对于更大规模的游戏。

  2. 现在检查计算出的角度是否小于在angle字段中指定的角度。注意,如果我们设置角度为90,实际上会是180,因为如果Vector3.Angle函数返回,例如,30,它可能是向左或向右的30。如果我们的角度是90,它可能是向左和向右的90,因此它将检测到 180 度弧内的物体。

  3. 使用 Physics.Linecast 函数在第一个和第二个参数(我们的位置和碰撞体位置)之间创建一个假想线,以检测第三参数(障碍层)中指定的层上的对象,并返回一个布尔值,指示该射线是否击中了某个对象。

    策略是使用线来检测我们和检测到的碰撞体之间是否存在任何障碍物,如果没有障碍物,这意味着我们有一个直接视线通向该对象。观察我们在 图 9.6 中如何使用 !not 操作符来检查 Physics.Linecast 没有检测到任何对象。再次注意,这个函数依赖于具有碰撞器的障碍物对象,在我们的例子中,我们有(墙壁、地板等):

    图 9.6:使用 Linecast 检查传感器和目标对象之间的障碍物

  4. 如果对象通过了这三个检查,这意味着这是我们目前看到的对象,因此我们可以将其保存在 Collider 类型的字段 detectedObject 中,以便稍后由其他 AI 脚本使用该信息。

    考虑使用 break 来停止迭代碰撞体的 for 循环,以防止通过检查其他对象而浪费资源,并在 for 之前将 detectedObject 设置为 null 以清除前一帧的结果。如果在当前帧中没有检测到任何内容,它将保持 null 值,这样我们就会注意到传感器中没有东西:

    图 9.7:完整的传感器脚本

    在我们的例子中,我们只是使用传感器来寻找玩家(传感器负责寻找的唯一对象),但如果你想让传感器更高级,你只需保留一个检测到的对象列表,在其中放置通过三个测试的每个对象,而不仅仅是第一个。在我们的例子中,这并不必要,因为我们游戏中只有一个玩家。

  5. 在编辑器中,根据您的意愿配置传感器。在这种情况下,我们将 Objects Layer 设置为 Player,这样我们的传感器就会专注于搜索具有该层的对象,并将 Obstacles Layer 设置为 Default,这是我们用于墙壁和地板的层。记住,Sight 脚本位于 AI GameObject 中,它是 Enemy 预制件的子对象:

图 9.8:传感器设置

  1. 要测试这一点,只需在玩家前方放置一个移动速度为 0 的敌人,选择其 AI 子对象,然后玩游戏以查看属性在检查器中的设置情况。此外,尝试在两者之间放置一个障碍物,并检查属性是否显示 None (null)。如果您没有得到预期的结果,请仔细检查您的脚本、其配置以及玩家是否具有 Player 层,障碍物是否具有 Default 层。您可能还需要将 AI 对象稍微抬高一点,以防止射线从地面以下开始并击中它。请随意进行进一步测试,以真正理解您的代码。

现在我们已经了解了 C#中传感器的工作原理,让我们看看可视化脚本版本。

使用可视化脚本创建三过滤传感器

关于可视化脚本版本,让我们逐部分检查,从重叠球体开始:

图 9.9:可视化脚本中的重叠球体

到目前为止,我们只是在将sensedObject变量设置为null后调用了重叠球体。上一张图片不仅包含了我们目前需要的变量,还包括了我们以后会使用的变量,所以请记住创建所有这些变量。需要考虑的一点是,在检查器中的变量组件中的sensedObject变量可能在你的情况下具有Null类型,这意味着在可视化脚本中没有类型。这在 C#中是不可能的——所有变量都必须有类型——虽然我们可以将sensedObject变量设置为正确的类型(Collider),但我们将会通过脚本将其变量类型留待以后设置。即使我们现在设置类型,如果没有设置值,可视化脚本往往会忘记类型,并且我们无法设置它,直到我们检测到某个东西。

目前不必担心这个问题;当我们通过我们的脚本设置变量时,它将获得正确的类型。实际上,在可视化脚本中,所有变量都可以在运行时根据我们设置的值切换类型,这取决于变量组件的工作方式。我不建议在运行时更改变量的类型,因为这会给它赋予不同的含义。尽量坚持预期的变量类型。

我们刚才说过,C#中的所有变量都必须有类型,但这并不完全正确。有方法可以创建动态类型的变量,但这不是一种我推荐使用的良好实践,除非没有其他选择。

另一点要注意的是,我们是如何在开始时使用Null节点将sensedObject变量设置为null,这实际上代表了null值。

现在,让我们探索一下Foreach部分:

图 9.10:在可视化脚本中迭代集合

我们可以看到,重叠球体的一个输出引脚有一个列表图标,这本质上代表了由重叠球体返回的collider数组。我们将该引脚连接到For Each Loop节点,正如你可能想象的那样,它遍历提供的集合(数组、列表、字典等)的元素。Body引脚代表循环中要执行的节点,Item输出引脚代表当前正在迭代的项——在我们的情况下,是重叠球体检测到的其中一个碰撞体。最后,我们将该项保存在一个Flow potentialDetection变量中(Flow变量在 C#函数中相当于局部变量)。

为了保持我们的视觉脚本图清晰,避免杂乱,我们将当前迭代的碰撞体分配给一个名为potentialDetectionFlow变量。这种方法消除了在整个图中进行大量连接的需要,简化了视觉布局和随后对该碰撞体的引用。

现在让我们探索角度检查:

图 9.11:视觉脚本中的角度检查

这里,你可以看到我们将 C#中检测角度所做操作的直接翻译,因此应该很容易理解。

现在,让我们探索Linecast部分:

图 9.12:视觉脚本中的 Linecast 检查

再次强调,这基本上与我们之前所做的是一样的。这里要强调的唯一一点是我们使用了Flow变量potentialDetection来再次获取当前迭代的项的位置,而不是将Get Position节点连接到 For Each Item 输出引脚。

现在,让我们探索最后一部分:

图 9.13:设置 sensedObject

再次强调,这部分内容基本上是自我解释的;如果Linecast返回False,我们将potentialDetection变量(当前迭代的项)设置为sensedObject变量(稍后其他脚本将访问的变量,以查询现在我们的 AI 可以看到哪个对象)。这里需要考虑的是Break Loop节点的使用,它等同于 C#中的break关键字;本质上,我们正在停止我们当前所在的Foreach循环。

现在,即使我们的传感器正在工作,有时,检查它是否工作或配置正确需要一些我们可以使用 gizmos 创建的视觉辅助工具。

使用 gizmos 进行调试

在创建我们的 AI 时,我们将开始检测边缘情况中的某些错误,通常与配置错误有关。你可能认为玩家在敌人的视线范围内,但也许你看不到视线被一个物体遮挡,尤其是在敌人不断移动的情况下。调试这些场景的一个好方法是使用仅编辑器可用的视觉辅助工具,即 gizmos,它允许你可视化诸如视线距离或用于检测障碍物的Linecasts等不可见数据。

让我们通过以下步骤开始了解如何创建绘制表示视线距离的球体 gizmos:

  1. Sight脚本中,创建一个名为OnDrawGizmos的事件函数。这个事件仅在编辑器中执行(不在构建中),是绘制任何 Unity 中 gizmos 的地方。

  2. 使用Gizmos.DrawWireSphere函数,将我们的位置作为第一个参数,将距离作为第二个参数,在我们的位置上绘制一个半径为我们的距离的球体。你可以检查当改变distance字段时 gizmo 的大小如何变化:

图 9.14:球体 gizmo

  1. 可选地,你可以在调用绘图函数之前设置 Gizmos.color 来更改 gizmo 的颜色:图片

    图 9.15:Gizmos 绘制代码

    现在你正在不断绘制 gizmos,如果你有很多敌人,它们可以用太多的 gizmos 污染场景视图。在这种情况下,尝试使用 OnDrawGizmosSelected 事件函数,它只在对象被选中时绘制 gizmos。

  2. 我们可以使用 Gizmos.DrawRay 来绘制代表锥体的线条,它接收要绘制的线条的起点和方向,方向可以通过乘以一个特定的值来指定线条的长度,如下面的截图所示:

图片

图 9.16:绘制旋转线条

  1. 在截图,我们使用了 Quaternion.Euler 来根据我们想要旋转的角度生成一个四元数。四元数是一种数学结构,用于表示旋转;请搜索此术语以获取更多关于它的信息。如果我们用一个方向乘以这个四元数,我们将得到旋转后的方向。我们正在根据 angle 字段旋转我们的前进向量以生成我们的锥形视觉线条。

此外,我们将这个方向乘以视线距离来绘制线条,直到我们的视线所能看到的地方;你会看到线条如何与球体的末端匹配:

图片

图 9.17:视觉角度线条

我们还可以绘制线条投射,它们检查障碍物,但它们依赖于游戏的当前情况,例如通过前两个检查的对象及其位置,我们可以使用 Debug.DrawLine 代替,它可以在 Update 方法中执行。这种 DrawLine 版本是为运行时设计的。我们看到的 gizmos 也在编辑器中执行。让我们以下面的方式尝试它们:

  1. 首先,让我们调试 Linecast 没有检测到任何障碍物的情况,因此我们需要在传感器和物体之间绘制一条线。我们可以在调用 Linecastif 语句中调用 Debug.DrawLine,如下面的截图所示:

图片

图 9.18:在 Update 中绘制线条

  1. 在下一张截图,你可以看到 DrawLine 的实际应用:

图片

图 9.19:指向检测到的物体的线条

  1. 我们还希望在视线被物体遮挡时用红色绘制线条。在这种情况下,我们需要知道线条投射击中的位置,因此我们可以使用函数的重载,它提供了一个 out 参数,它提供了更多关于碰撞的信息,例如击中的位置、法线和碰撞的物体,如下面的截图所示:图片

    图 9.20:获取 Linecast 的信息

    使用out关键字声明的参数允许方法通过参数返回数据。有关此信息的更多信息,请查看以下链接:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/out

    注意,Linecast并不总是与最近的障碍物碰撞,而是与它在直线上检测到的第一个对象碰撞,这可能会变化。如果你需要检测最近的障碍物,请查找函数的Physics.Raycast版本。

  2. 我们可以使用这些信息在if语句的else部分绘制从我们的位置到击中点的线条,当线条与某个物体碰撞时:

图 9.21:如果我们有障碍物,则绘制线条

  1. 在下一张屏幕截图中,你可以看到结果。确保在场景视图工具栏中开启Gizmos选项(最右侧的球形图标):

图 9.22:当障碍物遮挡视线时的线条

关于视觉脚本版本,第一部分将看起来像这样:

图 9.23:使用视觉脚本绘制辅助工具

然后,角度线将看起来像这样:

图 9.24:在视觉脚本中绘制视线角度线

注意,这里我们只展示了一个;另一个基本上是相同的,但将角度乘以-1。最后,指向检测到的对象和障碍物的红色线条将看起来像这样:

图 9.25:在视觉脚本中绘制指向障碍物或检测到的对象的线条

注意,为了完成这个最后一步,我们需要将之前的Linecast节点更改为返回Raycast Hit信息的版本。

在本节中,我们创建了将赋予我们的 AI 视力和大量关于下一步做什么的信息的传感器系统。现在我们已经完成了传感器,让我们使用它们提供的信息使用 FSM 做出决策。

使用 FSM 做出决策

在过去,当我们使用Animator组件时,我们探索了有限状态机(FSM)的概念。为了回顾,我们建议复习第十七章动画现实:使用 Animator、Cinemachine 和 Timeline 创建动画。我们了解到,FSM 是一组状态,每个状态代表一个对象在某一时刻可以执行的动作以及一组转换,这些转换决定了状态如何切换。这个概念不仅用于动画,而且在许多编程场景中都有应用,其中之一就是人工智能。对于人工智能,每个状态将代表在某一时刻可能激活的不同人工智能行为,转换将代表需要满足的条件,以便其他人工智能行为能够激活。例如,在射击游戏中,敌人可以具有诸如空闲巡逻攻击逃跑躲避等状态。

为了进一步强化 FSM 概念,我们建议回顾以下链接:gameprogrammingpatterns.com/state.html

在本节中,我们将检查以下 AI FSM 概念:

  • 在 C#中创建 FSM

  • 创建转换

  • 在视觉脚本中创建 FSM

让我们通过创建一个 C#中的 FSM 来开始实现这个 FSM 理论。

在 C#中创建 FSM

要创建我们自己的 FSM,我们需要回顾一些基本概念。记住,FSM 可以为它可能执行的每个动作都有一个状态,并且一次只能执行一个。

在 AI 方面,例如,我们可以巡逻、攻击、逃跑等等。同时,记住状态之间有转换,这些转换决定了从一个状态转换到另一个状态所需满足的条件,在 AI 方面,这可能是用户靠近敌人开始攻击或生命值低开始逃跑。在下一张图中,你可以找到一个简单的门两种可能状态的提醒示例:

状态图 - 维基百科

图 9.26:FSM 骨架

实现 AI 的 FSM 有几种方法;如果你想的话,甚至可以使用Animator组件或从资源商店下载 FSM 系统。在我们的案例中,我们将采取最简单的方法——一个包含一系列if语句的脚本,这可能是基本的,但仍然是理解概念的好开始。让我们通过以下步骤来实现它:

  1. 在敌人的AI子对象中创建一个名为EnemyFSM的脚本。

  2. 创建一个名为EnemyState的枚举,包含GoToBaseAttackBaseChasePlayerAttackPlayer值。我们将在我们的 AI 中拥有这些状态。

  3. 创建一个名为currentStateEnemyState类型字段,它将保存我们敌人的当前状态:图 9.26:FSM 骨架

    图 9.27:EnemyFSM 状态定义

    关于枚举如何工作,我们建议查看以下链接:learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/enum

  4. 创建三个以我们定义的状态命名的函数。

  5. 根据当前状态在Update中调用这些函数:图 9.28:状态测试

    图 9.28:基于 if 的 FSM

    是的,你完全可以在这里使用 switch,但我只是更喜欢这个示例中的常规if语法。

  6. 在编辑器中测试如何通过更改currentState字段来改变活动状态,查看在控制台窗口中打印的消息:

计算机截图  描述自动生成,置信度中等

图 9.29:状态测试

如您所见,这是一个相当简单但完全实用的方法。在未来,您可能需要编写具有更多状态的游戏敌人,这种方法将开始变得不适用。在这种情况下,您可以使用 Asset Store 中您喜欢的任何 FSM 插件来获得更强大和可扩展的工具,或者甚至考虑更高级的技术,如行为树,但这超出了本书的范围。现在让我们继续使用 FSM,通过创建其转换。

创建转换

如果您记得在 Animator Controller 中创建的转换,那些基本上是一系列检查转换所属状态是否激活的条件。在我们的 FSM 方法中,这简单地转化为检测状态内部条件的if语句。让我们按照以下方式创建我们提议状态之间的转换:

  1. 在我们的 FSM 脚本中添加一个名为sightSensorSight类型字段,并将AIGameObject 拖拽到该字段以连接到那里的Sight组件。由于 FSM 组件与Sight组件位于同一对象中,我们也可以使用GetComponent,但在高级 AI 中,您可能有不同的传感器来检测不同的对象,所以我更喜欢为那种情况准备我的脚本。您应该选择您最喜欢的做法。

  2. GoToBase函数中,检查Sight组件检测到的对象是否不为null,这意味着在我们的视线中有物体。如果我们的 AI 正在向基地移动但检测到障碍物,我们必须切换到Chase状态以追逐玩家,因此我们改变状态,如下截图所示:

图 9.30:创建转换

  1. 此外,如果我们离必须损坏的对象足够近,为了减少基础生命值,我们必须切换到AttackBase。我们可以创建一个类型为Transform的字段,命名为baseTransform,并将之前创建的玩家基础生命值对象拖拽到那里,以便我们可以检查距离。记得添加一个名为baseAttackDistancefloat字段,以便可以配置该距离:

计算机代码屏幕截图,自动生成描述,置信度低

图 9.31:GoToBase 转换

  1. ChasePlayer的情况下,我们需要检查玩家是否在视线之外,以便切换回GoToBase状态,或者我们是否足够接近玩家以开始攻击它。我们需要另一个名为PlayerAttackDistancedistance字段,它决定了攻击玩家的距离,我们可能希望为这两个目标设置不同的攻击距离。考虑在转换中提前返回,以防止在没有任何传感器检测到的对象时尝试访问传感器检测到的对象的位置时出现null引用异常:

包含文本、截图、字体描述的图片,自动生成描述

图 9.32:追逐玩家转换

  1. 对于AttackPlayer,我们需要检查玩家是否在视线之外,以便返回GoToBase,或者它是否足够远,可以返回追逐它。您会注意到我们是如何将playerAttackDistance乘以,使停止攻击的距离略大于开始攻击的距离;这将防止当玩家接近那个距离时在攻击和追逐之间快速切换。

  2. 您可以将它设置为可配置的,而不是硬编码1.1

计算机代码屏幕截图,自动生成描述,置信度低

图 9.33:攻击玩家状态转换

  1. 在我们的例子中,AttackBase 不会有任何转换。一旦敌人足够接近基地以攻击它,它就会保持这种状态,即使玩家开始射击它。一旦到达那里,它的唯一目标就是摧毁基地。

  2. 记住您可以使用Gizmos来绘制距离:

包含文本、截图、字体、行的图片,自动生成描述

图 9.34:有限状态机(FSM)组件

  1. 通过在点击播放之前选择AI对象,然后移动玩家,检查检查器面板中状态的变化来测试脚本。您还可以保留每个状态中的原始print消息,以在控制台窗口中查看它们的变化。请记住设置攻击距离和对象引用。在屏幕截图中,您可以查看我们使用的设置:

计算机屏幕截图,自动生成描述

图 9.35:敌人有限状态机(FSM)设置

我们现在将遇到的一个小问题是生成的敌人将没有需要的引用来计算到玩家基地变换的距离。您会注意到,如果您尝试将场景中的敌人更改应用到预制件(覆盖 | 应用全部),则基地变换变量将显示为None。请记住,预制件不能包含对场景中对象的引用,这使我们的工作变得复杂。一个替代方案是创建BaseManager,一个单例,它持有对损伤位置的引用,这样我们的EnemyFSM就可以访问它。另一个方案是利用GameObject.Find等函数来查找我们的对象。

在这种情况下,我们将使用后者。尽管它可能不如Manager版本性能好,但我想要向您展示如何使用它来扩展您的 Unity 工具集。在这种情况下,只需将baseTransform字段在Awake中设置为GameObject.Find的返回值,使用BaseDamagePoint作为第一个参数,这将查找具有相同名称的对象,如下面的屏幕截图所示。

您会看到现在我们生成的敌人波次将改变状态:

包含文本、字体、行、截图的图片,自动生成描述

图 9.36:通过名称在场景中搜索对象

当我开始学习关于游戏人工智能时,我以为我会使用像深度学习这样的复杂算法来创建天网。正如你所见,我们离那个目标还很远,原因在于游戏人工智能不需要智能,它需要的是趣味性。要实现这一点,需要精心设计来生成确切期望的体验,而这可能使用尖端的人工智能技术很难实现。

话虽如此,还有其他人工智能技术,比如行为树,你可以在本篇光环开发者文章中了解更多:www.gamedeveloper.com/programming/gdc-2005-proceeding-handling-complexity-in-the-i-halo-2-i-ai

另一个名为 GOAP 的替代方案,我建议阅读来自 F.E.A.R.开发者的这篇论文:www.gamedevs.org/uploads/three-states-plan-ai-of-fear.pdf 最后,还有游戏人工智能专业书籍系列,它收集了多篇与游戏人工智能相关的论文,你可以在这里了解更多:www.gameaipro.com/

在本节中,我们使我们的有限状态机(FSM)能够根据传感器和其他来源提供的数据正确切换状态,为开始编写每个状态的实际行为奠定基础。现在我们的 FSM 状态已经编码并且能够正确执行转换,让我们看看如何在视觉脚本中做到同样的事情。如果你只对 C#版本感兴趣,可以自由跳过以下部分。

在视觉脚本中创建 FSM

到目前为止,大多数视觉脚本中的脚本几乎是对 C#版本的镜像,只是在一些节点上有所不同。至于状态机,我们本可以做到一样;相反,我们将使用视觉脚本的状态机系统。概念是相同的——你有状态并且可以切换它们——但是状态的组织方式和何时触发转换是由视觉管理的,这与动画器系统类似。所以,让我们看看如何通过创建我们的第一个状态机图和一些状态来使用这个系统。按照以下步骤操作:

  1. 状态机组件添加到我们的敌人身上。请记住,它被称为状态机,而不是脚本机,后者是常规视觉脚本的组件。

  2. 在组件中点击新建按钮,并选择一个位置来保存固定资产,方式与我们迄今为止为常规视觉脚本所做的一样。在我的例子中,我将其命名为EnemyFSM

状态机的截图,描述由中等置信度自动生成

图 9.37:创建视觉脚本状态机

  1. 双击状态机图以像往常一样编辑它。

  2. 编辑器的任何空白区域右键单击,然后选择创建脚本状态以创建一个新状态:

包含文本、截图、字体、矩形的图片,描述由自动生成

图 9.38:创建我们的第一个可视化脚本状态机状态

  1. 重复步骤 4,直到您拥有四个状态:

图 9.39:可视化脚本状态

  1. 选择任何一个状态,然后在左侧的信息面板中,将标题字段(第一个字段)填写为我们之前创建的任何状态的名称(GoToBaseAttackBaseChasePlayerAttackPlayer)。如果您看不到信息面板,请点击中间带有i的按钮以显示它:

计算机截图,描述由中等置信度自动生成

图 9.40:重命名可视化脚本状态

  1. 对其余的状态节点重复此操作,直到每个节点都按照本章在 C# 中创建有限状态机部分创建的状态命名:

计算机程序截图,描述由低置信度自动生成

图 9.41:所有所需的状态

  1. 您可以看到其中一个状态顶部有一个绿色条,这代表哪个节点应该是第一个。我将那个初始状态重命名为GoToBase,因为这是我首选的第一个状态。如果您没有将其作为起始状态,请右键单击当前具有绿色条的状态机中的节点,选择切换起始以从它中移除绿色条,然后对您想要作为第一个的节点(在我们的场景中是GoToBase)重复此操作,将绿色条添加到该节点上。

  2. 需要考虑的是,在可视化脚本中可以有一个以上的起始状态,这意味着您可以同时运行多个状态并进行转换。如果可能,我建议一次不要激活多个状态,以使事情变得简单。

  3. 双击GoToBase以进入这些状态的编辑模式。将一个字符串节点连接到OnUpdate事件节点中的打印 消息输入引脚,以打印一条表示GoToBase的消息:

计算机程序截图,描述由低置信度自动生成

图 9.42:我们的第一个状态机逻辑

  1. 在顶部栏中,点击GoToBase左侧的EnemyFSM标签以返回到整个状态机视图。如果您看不到它,请点击第三个按钮(看起来像)右侧的任何文本标签:

图 9.43:返回到状态机编辑模式

  1. 如果您不打算使用它们,可以随意删除其他事件节点。

  2. 对每个状态重复执行步骤 911,直到所有状态都打印出它们的名称。

通过这种方式,我们已经创建了表示我们 AI 可能状态的节点。在下一节中,我们将为它们添加逻辑,使其变得有意义,但在那之前,我们需要创建状态之间的转换以及触发它们的条件,具体操作如下:

  1. 在敌人的变量组件中创建变量baseTransformbaseAttackDistanceplayerAttackDistance,因为我们需要它们来进行转换。

  2. 不要将任何类型设置为baseTransform,因为我们将在稍后通过代码填充它,但关于baseAttackDistance,使用Float类型并设置值为2,最后,对于playerAttackDistance,也使用Float并设置值为3。如果您愿意,可以更改这些值:

计算机程序截图  描述由低置信度自动生成

图 9.44:我们转换所需的变量

  1. 右键单击GoToBase节点,选择创建转换选项,然后单击ChasePlayer节点。这将在这两个状态之间创建一个转换:

图片

图 9.45:两个状态之间的转换

  1. 对于在 C#版本中创建的每个转换,重复步骤 3状态机图需要看起来像以下截图:

图片

图 9.46:所有需要的转换

  1. 双击GoToBaseChasePlayer之间转换中间的黄色形状以进入转换模式。在这里,您将能够指定将触发该转换的条件(而不是在状态逻辑中使用If节点)。请记住,您有两个黄色形状,每个方向一个,所以请确保您双击的是基于连接它们的白色箭头的正确形状。

  2. 修改图以检查sensedObject变量是否不是null。它应该看起来像这样:

图片

图 9.47:添加转换条件

  1. GoToBaseAttackBase之间的转换应该如下所示:

图片

图 9.48:GoToBase 到 AttackBase 的转换条件

  1. 现在,ChasePlayerGoToBase应该如下所示:

图片

图 9.49:ChasePlayer 到 GoToBase 的转换条件

  1. 对于ChasePlayerAttackPlayer的转换,按照图 9.50中的操作进行。这本质上与GoToBaseAttackBase(距离检查)相同,但目标不同:

图片

图 9.50:ChasePlayer 到 AttackPlayer 的转换条件

  1. 对于AttackPlayerChasePlayer的转换,按照图 9.51中的操作进行。这是另一个距离检查,但现在检查距离是否更大,并将距离乘以1.1(以防止转换抖动,如我们在 C#版本中解释的):

图片

图 9.51:AttackPlayer 到 ChasePlayer 的转换条件

  1. 最后,对于 AttackPlayerGoToBase,这是预期的图:

图片

图 9.52:从 AttackPlayerGoToBase 的转换条件

在继续之前,我们需要解决的一个小细节是我们还没有在 baseTransform 变量中设置任何值。想法是通过代码填充它,就像我们在 C# 版本中所做的那样。但这里需要考虑的是,我们无法将 Awake 事件节点添加到整个状态机中,而只能添加到状态中。

在这个场景中,我们可以使用 OnEnterState 事件,这是状态机的专用事件节点。它将在状态变为活动状态时立即执行,这对于状态初始化很有用。我们可以将初始化 baseTransform 变量的逻辑添加到 GoToBase 状态的 OnEnterState 事件节点中,前提是它是我们首先执行的状态。

这样,GoToBase 逻辑将看起来像 图 9.53。请记住双击状态节点来编辑它:

图片

图 9.53:GoToBase 的初始化逻辑

注意,在这里,我们将 Find 节点的结果仅设置到 Null CheckNull 插针上的变量中。Null Check 的作用是检查我们的 baseTransform 变量是否已设置,如果是,则通过 Not Null 插针,如果不是,则通过 Null 插针。这样,我们就可以避免每次进入 GoToBase 状态时都执行 GameObject.Find,而只需第一次执行。此外,请注意,在这种情况下,我们将不仅当对象初始化时执行 Set Variable 节点,而且每次 GoToBase 成为当前状态时也会执行。如果这导致意外的行为,其他选项可能是创建一个新的初始状态,初始化所有内容,然后过渡到其他状态,或者也许可以创建一个经典的视觉脚本图,在 On Start 事件节点中初始化这些变量。

通过所有这些,我们学习了如何通过 FSM 创建 AI 的决策系统。它将根据通过传感器和其他系统收集的信息做出决策。现在我们的 FSM 状态已经编码并且过渡得当,让我们让它们做一些事情。

执行 FSM 动作

现在我们需要完成最后一步——让 FSM 做一些有趣的事情。在这里,我们可以做很多事情,比如射击基地或玩家,并将敌人移动到其目标(基地或玩家)。我们将使用 Unity 的寻路系统 NavMesh 来处理移动,这是一个允许我们的 AI 在两点之间计算和遍历路径,同时避开障碍物的工具,它需要一些准备才能正常工作。

在本节中,我们将检查以下 FSM 动作概念:

  • 计算场景的 NavMesh

  • 使用寻路算法

  • 添加最终细节

让我们先为使用寻路算法的场景做准备。

计算场景的 NavMesh

寻路算法依赖于场景的简化版本。分析复杂场景的完整几何形状在实时中几乎是不可能的。有几种方式可以表示从场景中提取的寻路信息,例如图和NavMesh几何形状。Unity 使用后者——一个类似于 3D 模型的简化网格,它跨越 Unity 确定的所有可通行区域。在下一张截图,你可以找到一个场景中生成的NavMesh示例——即,浅蓝色几何形状:

图 9.54:场景中可通行区域的 NavMesh

生成NavMesh可能需要几秒到几分钟,这取决于场景的大小。这就是为什么 Unity 的寻路系统在编辑器中只计算一次NavMesh,这样当我们发布我们的游戏时,用户将使用预先生成的NavMesh。在之前的 Unity 版本中,就像光照贴图一样,NavMesh通常会被烘焙到一个文件中以便以后使用。这意味着对NavMesh表面有贡献的 GameObject 曾经是静态的,并且在运行时不能对场景进行任何修改。Unity 中新的 AI 导航系统的主要优势是NavMesh对象现在可以在运行时改变。如果你摧毁或移动一个地板砖,AI 仍然会调整其行为以在该区域行走、停留或跌倒。这意味着如果在游戏过程中摧毁地板砖,NavMesh会动态更新以反映这一变化,显示 AI 不能再行走的区域。我们将安装并使用 AI 导航包来将这种行为添加到我们的游戏中。

要为我们的场景生成NavMesh,请执行以下操作:

  1. 打开包管理器(窗口 | 包管理器)。

  2. 下拉菜单设置为Unity 注册表模式。

  3. 在列表中搜索一个名为 AI 导航的包。这个包将使我们能够访问新的组件,这些组件将帮助我们定义哪些表面是可走的,哪些代理可以走在上面。在撰写本书时,这个包的当前版本是 1.1.4:

图 9.55:安装 AI 导航包

  1. NavMeshSurface组件添加到可通行表面。

  2. 从最近添加的组件中,点击窗口底部的烘焙按钮,并检查生成的NavMesh

图 9.56:生成 NavMesh

大概这就是你需要做的所有事情。当然,在这个组件中有很多设置你可以调整,例如最大坡度,它表示 AI 能够爬升的最大角度,或者步高,这将决定 AI 是否能够爬楼梯,连接NavMesh中楼梯之间的楼层,但鉴于我们有一个简单直接的场景,默认设置就足够了。

如果你想玩弄它们,你可以转到菜单栏并选择窗口 | AI | 导航。从那里,你将能够调整所有这些参数并重新烘焙NavMeshSurface以根据 AI 代理的大小调整可通行区域。

在我们场景的NavMesh设置完成后,我们已经为复杂的 AI 移动打下了基础。让我们通过编程我们的 AI 在游戏世界中导航来观察这一过程。

使用路径查找

为了制作一个使用NavMesh移动的 AI 对象,Unity 提供了NavMeshAgent组件,这将使我们的 AI 粘附在NavMesh上,防止对象超出其范围。它不仅会自动计算到指定目的地的路径,还会使用引导行为算法通过路径移动对象,模仿人类通过路径移动的方式,在拐角处减速,并通过插值而不是瞬间转向。此组件还确保 AI 角色彼此避免。它通过将每个角色引导远离其他角色来防止拥挤,保持游戏中的自然流动。

让我们通过以下方式使用这个强大的组件:

  1. 选择敌人预制体,并向其添加NavMeshAgent组件。将其添加到根对象中,即名为Enemy的对象,而不是 AI 子对象——我们希望整个对象都能移动。你将看到围绕对象的一个圆柱体,表示对象在NavMesh中将占用的区域。注意,这并不是一个碰撞器,因此它不会被用于物理碰撞:

图片

图 9.57:NavMeshAgent 组件

  1. 删除ForwardMovement组件;从现在起,我们将用NavMeshAgent驱动敌人的移动。

  2. EnemyFSM脚本的Awake事件函数中,使用GetComponentInParent函数将NavMeshAgent的引用缓存到一个新的private变量中。这会与GetComponent类似工作——它将在我们的 GameObject 中查找组件,但如果组件不存在,这个版本将尝试在所有父组件中查找该组件。记得添加using UnityEngine.NavMesh行以在脚本中使用NavMeshAgent类!图片

    图 9.58:缓存父组件引用

    如你所想,还有一个GetComponentInChildren方法,它首先在GameObject中搜索组件,如果需要,再在其所有子组件中搜索。

  3. GoToBase状态函数中,调用NavMeshAgent引用的SetDestination函数,传入基地对象的坐标作为目标:

图片

图 9.59:为我们的 AI 设置目的地

  1. 保存脚本并在场景中的几个敌人或由波次生成的敌人上测试这个脚本。你会看到问题,敌人永远不会停止向目标位置移动,如果需要,甚至进入对象内部,即使他们的有限状态机(FSM)的当前状态在它们足够接近时发生变化。这是因为我们从未告诉NavMeshAgent停止,我们可以通过将智能体的isStopped字段设置为true来实现这一点。

  2. 你可能想要调整基础攻击距离,使敌人停止得更近或更远一些:

图 9.60:停止智能体移动

  1. 我们可以为ChasePlayerAttackPlayer做同样的事情。在ChasePlayer中,我们可以将智能体的目的地设置为玩家的位置,在AttackPlayer中,我们可以停止移动。在这种情况下,AttackPlayer可以再次回到GoToBaseChasePlayer,所以你需要在这些状态中将智能体的isStopped字段设置为false或在进行转换之前。我们将选择前者,因为这个版本将涵盖其他也需要停止智能体的状态,而无需额外的代码。我们将从GoToBase状态开始:

图 9.61:重新激活智能体

  1. 然后,继续使用ChasePlayer

图 9.62:重新激活智能体并追逐玩家

  1. 最后,继续使用AttackPlayer

图 9.63:停止移动

  1. 你可以调整NavMeshAgent加速度速度角速度属性来控制敌人移动的速度。平衡这些设置以确保 AI 在游戏中的移动方式合理。此外,请记住将更改应用到生成的敌人的 Prefab 中,以便受到影响。

  2. 关于视觉脚本版本,GoToBase将看起来如下截图所示:

包含文本、图表、字体、屏幕截图的图片,描述自动生成

图 9.64:使我们的智能体移动

  1. 我们删除了打印消息的OnUpdate事件节点,因为我们不再需要它。此外,我们在设置变量后调用了设置目的地节点,如果ifnull,以及当变量不为nullNull检查的Not Null引脚)时。请注意,所有这些都在On Enter State事件中发生,所以我们只需要做一次。我们在 C#版本中为每一帧做这件事是为了简单起见,但实际上并不必要,所以我们将利用On Enter State事件。如果我们想,我们可以在 C#版本中模拟这种行为,在改变状态时(在检查转换条件的if语句内部)执行这些动作,而不是使用Update函数。最后,注意我们为什么需要使用获取父节点来访问敌人根对象中的NavMeshAgent组件?这是因为我们目前处于AI子对象中。

  2. 现在,AttackBase状态将看起来如下:

计算机截图,描述由中等置信度自动生成

图 9.65:使我们的智能体停止

  1. ChasePlayer状态将看起来像这样:

计算机程序截图,描述由低置信度自动生成

图 9.66:ChasePlayer 逻辑

  1. 最后,AttackPlayer将看起来像这样:

img/B21361_09_67_PE.png

图 9.67:AttackPlayer 逻辑

虽然 Unity 有其自己的路径查找系统,但它并非唯一,可能也不适合更高级的游戏。我建议学习路径查找的基础知识,比如了解 BFS、Dijkstra 和 A*算法。如果你想要深入研究,你可以学习更高级的技术,比如这篇 Killzone 开发者论文中解释的战术路径查找:cse.unl.edu/~choueiry/Documents/straatman_remco_killzone_ai.pdf,或者这篇关于 Left 4 Dead AI 系统的演示:steamcdn-a.akamaihd.net/apps/valve/2009/ai_systems_of_l4d_mike_booth.pdf

现在随着我们的 AI 能够导航游戏世界,我们离拥有一个完全功能的敌人不远了。接下来,我们将添加一些细节,包括射击机制和动画,以完成 AI 的行为。

添加最终细节

这里我们缺少两件事:敌人没有射击任何子弹,并且它没有动画。让我们从修复射击开始做以下操作:

  1. 在我们的EnemyFSM脚本中添加一个GameObject类型的bulletPrefab字段和一个名为fireRatefloat字段。

  2. 创建一个名为Shoot的函数,并在AttackBaseAttackPlayer中调用它:

img/B21361_09_68_PE.png

图 9.68:射击函数调用

  1. Shoot函数中,放入与PlayerShooting脚本中使用的类似代码,以特定射速射击子弹,如图 9.68所示。记得如果你的Enemy Prefab 中还没有设置,设置Enemy层,以防止子弹伤害到敌人本身。你可能还希望稍微提高AI GameObject 的位置,以便从地面以外的位置射击子弹,或者更好的是,添加一个shootPoint变换字段,并在敌人中创建一个空对象作为生成位置。如果你这样做,请考虑使空对象不旋转,以便敌人的旋转能够正确地影响子弹的方向:img/B21361_09_69.png

    图 9.69:射击函数代码

    在这里,你可以在PlayerShootingEnemyFSM之间找到一些重复的射击行为。你可以通过创建一个名为Shoot的函数的Weapon行为来修复这个问题,该函数实例化子弹并考虑射速,并在两个组件内部调用它以重新利用它。

  2. 当代理停止时,不仅移动停止,旋转也会停止。如果玩家在敌人被攻击时移动,我们仍然需要敌人面对玩家以射击子弹。我们可以创建一个LookTo函数,该函数接收要观察的目标位置,然后在AttackPlayerAttackBase中调用它,传递要射击的目标:

计算机代码的屏幕截图  描述由中等置信度自动生成

图 9.70:LookTo 函数调用

  1. 通过计算我们的父对象到目标位置的方向来完成LookTo函数。我们通过transform.parent访问我们的父对象,因为,记得,我们是子AI对象——将要移动的对象是我们的父对象。然后,我们将方向Y分量设置为0以防止方向向上或向下指向——我们不希望我们的敌人垂直旋转。最后,我们将父对象的正向向量设置为该方向,这样它就会立即面对目标位置。如果你想要更平滑的旋转,可以用四元数插值来替换,但现在让我们尽可能保持简单:

计算机代码的屏幕截图  描述由低置信度自动生成

图 9.71:朝向目标看去

  1. 关于视觉脚本版本,AttackBase动作看起来是这样的:

计算机程序的屏幕截图  描述由低置信度自动生成

图 9.72:AttackBase 状态

在这个状态下,我们有一些要强调的内容。首先,我们在Set Stopped节点之后,在On Enter State事件节点中使用了Look At节点。正如你可能想象的那样,这与我们在 C#中使用的数学方法相同。我们指定一个要观察的目标(我们的基地变换)并指定World Up参数是一个向上指向的向量0,1,0。这将使我们的对象朝向基地,但保持其向上向量指向天空,这意味着如果目标低于它,我们的对象不会朝向地面。

如果我们想的话,可以在 C#中使用这个确切的功能(transform.LookAt);只是想展示所有选项。另外,注意我们只在状态变为活动时执行LookAt;因为基地不动,我们不需要不断更新我们的方向。

第二个要强调的是,我们使用了协程来射击,这与我们在Enemy Spawner中不断生成敌人的相同思路。本质上,我们在Wait For SecondsInstantiate之间创建了一个无限循环。我们采取这种方法是因为它很方便,因为它在视觉脚本中需要的节点更少。

记得选择On Enter State节点并检查我们之前所做的Coroutine复选框。此外,我们还需要在EnemyAI子对象中添加一个新的Float类型变量,名为fireRate

计算机截图  描述自动生成,置信度中等

图 9.73:协程

然后,AttackPlayer 将看起来像这样:

计算机截图  描述自动生成,置信度中等

图 9.74:AttackPlayer 状态

实质上与AttackBase相同,但它面向的是sensedObject而不是玩家的基地,我们还把Look At节点作为无限循环的一部分,以便在射击前纠正敌人的航向,以瞄准玩家。

有了这些,我们已经完成了所有的 AI 行为。当然,这些脚本/图表足够大,未来值得重写和拆分,但有了这个,我们已经原型化了我们的 AI,我们可以测试它,直到我们满意为止,然后我们可以改进这段代码。

摘要

我很确定 AI 不是你想象的那样;你在这里不是在创造天网,但我们已经完成了一个简单但有趣的 AI 来挑战我们的玩家,我们可以迭代和调整以适应我们游戏预期的行为。我们看到了如何通过传感器收集周围信息,使用有限状态机(FSMs)和不同的 Unity 系统,如寻路系统,来决定执行哪些动作,从而使 AI 执行这些动作。我们使用这些系统来绘制一个能够检测玩家、向他们跑去并攻击他们的状态机,如果玩家不在那里,就前往基地完成任务,摧毁它。

随着我们进入下一章,我们将把重点转移到游戏开发的另一个重要方面:增强图形和音频。准备好深入创建将使你的游戏世界栩栩如生的材质和着色器。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家和作者本人一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything(AMA)会议与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

第三部分

提升视觉效果、效果和音频

通过使用粒子系统、定制着色器、复杂的照明、后期处理、动画、用户界面和声音设计,增强您现有的关卡,使其视觉效果引人注目。

本节包含以下章节:

  • 第十章材料炼金术:使用 URP 和 Shader Graph 实现惊人的视觉效果

  • 第十一章吸引人的视觉效果:利用粒子系统和视觉效果图

  • 第十二章照亮世界:使用通用渲染管道照亮场景

  • 第十三章沉浸式真实感:通过后期处理实现全屏效果

  • 第十四章和谐的音景:整合音频和音乐

第十章:材质炼金术:使用 URP 和 Shader Graph 创建惊人的视觉效果

欢迎来到第三部分提升视觉效果、效果和音频的第一章。在这里,我们将深入探讨 Unity 的不同图形和音频系统,以显著提升游戏的外观和感觉。让我们开始我们的着色器世界之旅,着色器是每个 Unity 游戏背后的艺术家,我们将学习如何从头开始制作自己的着色器。我们将从讨论什么是着色器以及如何创建自己的着色器以实现一些默认 Unity 着色器无法实现的自定义效果开始。我们将使用 Shader Graph 创建一个简单的水动画效果,Shader Graph 是包含在通用渲染管道(URP)中的可视化着色器编辑器,是创作者将游戏发布到包括网页和移动设备在内的各种设备上的首选选项。也称为 URP,这是 Unity 中可用的不同渲染管道之一,它提供面向性能的渲染功能。我们将在本章中讨论其一些功能。

在本章中,我们将探讨以下着色器概念:

  • 介绍着色器和 URP

  • 使用 Shader Graph 创建着色器

介绍着色器和 URP

记得我们在第一部分,Unity 入门中创建的那个发光球体材质吗?让我们探索其着色器属性如何操纵光线以创建那种发光效果。在本节中,我们将探讨着色器作为编程显卡以实现自定义视觉效果的方法。我们还将讨论 URP 如何与这些着色器一起工作,以及它提供的默认着色器。

在本节中,我们将介绍与着色器相关的以下概念:

  • 着色器管道

  • 渲染管道和 URP

  • URP 内置着色器

让我们先讨论一下着色器如何修改着色器管道以实现效果。

着色器管道

当显卡渲染 3D 模型时,它需要不同的信息来处理,例如网格纹理、对象的变换(位置、旋转和缩放)以及影响对象的光线。有了这些数据,显卡必须将对象的像素输出到后缓冲区,这是一个显卡将要绘制我们的对象但用户还看不到的图像。这样做是为了防止用户看到未完成的结果,因为我们在显示器刷新时仍在绘制。当 Unity 完成所有对象(和一些效果)的渲染以显示完成的场景时,将后缓冲区与用户实际看到的前缓冲区交换,这时图像才会显示出来。你可以想象这就像有一页带有图像的页面,在向用户展示图像的同时,你在绘制新的图像,当你完成新的绘制后,你只需交换页面,然后在用户看不到的页面上重新开始绘制,每帧重复这个过程。

这通常是渲染对象的方式,但从数据输入到像素输出的过程中可以采用无数种不同的方法和技巧来处理,这取决于你希望对象看起来如何;可能你希望它看起来逼真,或者像全息图一样,或者可能对象需要分解效果或卡通效果——可能性无穷无尽。指定我们的显卡如何处理对象渲染的方式是通过着色器来实现的。

着色器是一种用特定显卡语言编写的程序,例如:

  • HLSL:DirectX 着色语言,DirectX 是一个图形库。

  • GLSL:OpenGL 着色语言,OpenGL 也是一个图形库。

  • CG:一种可以输出 HLSL 或 GLSL 的语言,取决于我们在游戏中使用哪个图形库。

  • 着色器图:一种将根据我们的需求自动转换为之前提到的一种语言的视觉语言。这是我们将会使用的一种,因为它简单(后面会详细介绍)。

这些语言中的任何一种都可以用来配置渲染给定对象所需的渲染过程的各个阶段,有时不仅配置它们,还可以用完全定制的代码来替换它们,以实现我们想要的确切效果。渲染一个对象的各个阶段构成了我们所说的着色器管线,这是一系列应用于输入数据的修改,直到将其转换为像素。

管线中的每个阶段负责不同的修改,并且根据显卡的着色器模型,这个管线可能会有很大的变化。在下一张图中,你可以找到一个简化的着色器管线,跳过了现在不重要的高级/可选阶段:

一个蓝色矩形,上面有黑色文字  描述由低置信度自动生成

图 10.1:常见的着色器管线

将着色器管线想象成工厂中的一条装配线,其中每个阶段代表一位专注于特定任务的工人,他们共同协作完成最终产品。

让我们讨论每个阶段:

  • 输入汇编器:在这里,所有网格数据,如顶点位置、UV 和法线,被组装起来,为下一阶段做准备。

  • 顶点着色器:这个阶段过去仅限于应用对象的变换、摄像机的位置和透视以及简单的光照计算。在现代 GPU 中,你可以做任何你想做的事情。这个阶段接收要渲染的对象的每个顶点,并输出一个修改后的顶点。你有机会在这里修改对象的几何形状。这里的常用代码应用对象的变换,但你也可以应用多种效果,例如沿着法线膨胀对象以应用旧的卡通效果技术,或者应用扭曲,为每个顶点添加随机偏移以重新创建全息图。还有机会计算下一阶段所需的数据。

  • 原语裁剪: 您将要渲染的大多数模型具有一个特性,那就是您永远不会看到模型面的背面。对于一个立方体来说,没有方法可以看到它的内部侧面。鉴于这一点,渲染立方体每个面的两面是没有意义的,这个阶段就负责处理这个问题。原语裁剪将根据面的方向确定是否需要渲染面,从而节省大量被遮挡面的像素计算。您可以更改此设置以适应特定情况;例如,我们可以创建一个需要透明以看到盒子所有面的玻璃盒子。不要将此与其他类型的裁剪混淆,如视锥裁剪。这种其他类型的裁剪在将对象发送到着色器管道之前,会过滤掉相机视图区域外的对象。

  • 光栅化器: 现在我们已经计算出了模型修改后的可见几何形状,是时候将其转换为像素了。光栅化器为我们的网格三角形创建所有像素。这里发生了很多事情,但同样,我们对这些事情的控制非常有限;通常的渲染方法是仅创建网格三角形边缘内的所有像素。我们还有其他模式,仅渲染边缘上的像素以看到线框效果,但这通常用于调试目的:

光栅化器图像结果

图 10.2:图元光栅化的示例

  • 片段着色器: 这是所有阶段中最可定制的之一。它的目的是简单的:只是确定光栅化器生成的每个片段(像素)的颜色。在这里,可以发生很多事情,从简单地输出纯色或采样纹理到应用复杂的照明计算,如法线贴图和 PBR。此外,您还可以使用这个阶段创建特殊效果,例如水动画、全息图、扭曲、分解以及任何需要您修改像素外观的特殊效果。我们将在本章的下一节中探讨如何使用这个阶段。

  • 深度测试: 在屏幕上显示像素之前,我们需要检查它是否可见。这个阶段检查像素的深度是在之前在同一位置渲染的像素之前还是之后,确保无论对象的渲染顺序如何,离相机最近的像素总是绘制在其他像素之上。同样,通常这个阶段会保留默认状态,优先考虑离相机更近的像素,但某些效果需要不同的行为。此外,如今我们还有早期 Z 测试,它在这个阶段之前执行相同的测试。但让我们现在保持简单。例如,在下一张屏幕截图中,您可以看到一个效果,允许您看到位于其他对象后面的对象,就像在帝国时代中,一个单位在建筑物后面时的情况:

遮挡效果着色器图像结果

图 10.3:渲染角色的遮挡部分

  • 混合:一旦确定了像素的颜色并且我们确信该像素没有被之前的像素遮挡,最后的步骤就是将其放入后缓冲区(你正在绘制的帧或图像)。通常,我们只是覆盖掉该位置上原有的像素(因为我们的像素更靠近相机),但如果考虑透明物体,我们需要将我们的像素与之前的像素结合,以产生透明效果。透明度除了混合之外还有其他需要考虑的因素,但主要思想是混合精确控制像素如何与后缓冲区中之前渲染的像素结合。

着色器管线需要整本书来涵盖,但就本书的范围而言,前面的描述将给你一个关于着色器做什么以及它能实现的可能效果的良好概念。现在我们已经讨论了着色器如何渲染单个对象,值得讨论 Unity 如何使用渲染管线渲染所有对象。

关于着色器的更多信息,你可以使用以下链接:docs.unity3d.com/Manual/shader-writing.html

渲染管线和 URP

我们已经介绍了视频卡如何渲染一个对象,但 Unity 负责要求视频卡为每个对象执行一个着色器管线。为此,Unity 需要进行大量的准备工作并计算,以确定每个着色器确切地何时以及如何执行。执行这一职责的是 Unity 所说的渲染管线。

将 Unity 的渲染管线想象成一个电影导演,负责如何呈现每个场景(对象),其中 URP 就像其高级相机之一,优化每个镜头的捕捉。同时,将渲染管线想象成剧院的舞台工作人员,为每个对象(演员)设置场景和灯光,以确保他们在聚光灯下看起来最好。

渲染管线是绘制场景中对象的一种方式。起初,这听起来应该只有一个简单的方法来做这件事,例如,遍历场景中的所有对象,并使用每个对象材质中指定的着色器执行着色器管线,但它可能比这更复杂。

通常,一个渲染管线与另一个渲染管线之间的主要区别在于光照和一些高级效果的计算方式,但它们在其他方面也可能有所不同。

在之前的 Unity 版本中,只有一个单一的渲染管线,现在被称为内置渲染管线(也称为BIRP)。这是一个包含所有可能需要的功能的管线,适用于各种项目,从移动 2D 图形和简单的 3D 到像在游戏机或高端 PC 上找到的尖端 3D 图形。这听起来很理想,但实际上并非如此。拥有一个需要高度可定制以适应所有可能场景的单一大型的渲染器会产生大量的开销和限制,这比创建一个自定义渲染管线要麻烦得多。幸运的是,Unity 的最新版本引入了可脚本渲染管线SRP),这是一种创建适合您项目渲染管线的方法。

幸运的是,Unity 不希望您为每个项目创建自己的渲染管线(这是一个复杂的工作),因此它为您创建了两个可用的自定义管线:URP(以前称为 LWRP,或轻量级渲染管线),代表通用渲染管线,以及HDRP,代表高清渲染管线。想法是您必须根据项目需求选择其中一个(除非您真的需要创建自己的)。

URP,我们在创建游戏项目时选择的一个渲染管线,适用于大多数不需要大量高级图形功能的游戏,如移动游戏或简单的 PC 游戏,而 HDRP 则包含大量高级渲染功能,适用于高质量游戏。后者需要高端硬件才能运行,而 URP 几乎可以在所有相关目标设备上运行。值得一提的是,您可以在任何时候(包括在创建项目之后)在 BIRP、HDRP 和 URP 之间切换(但不推荐这样做):

图 10.4:项目向导显示 HDRP 和 URP 模板

我们可以讨论每个是如何实现的以及它们之间的区别,但同样,这可能会填满整个章节。目前,本节的想法是让您知道为什么我们在创建项目时选择了 URP;我们在本书中会遇到一些限制,我们需要考虑这些限制,所以了解我们为什么接受这些限制是好的。我们选择它的一个原因是可以让我们在所有相关硬件上运行我们的游戏。

我们选择 URP 的另一个原因是它支持 Shader Graph,这是我们在本章中使用的 Unity 工具,用于创建自定义效果。之前的 Unity 内置管线没有提供这样的工具(除了第三方插件)。最后,引入 URP 概念的另一个原因是它附带了许多内置着色器,在创建我们自己的着色器之前,我们需要了解这些着色器,以避免重复造轮子。这将使我们熟悉这些着色器,因为如果你来自 Unity 的早期版本,你已知的着色器在这里将不起作用;实际上,这正是我们将在本章下一节讨论的内容:不同 URP 内置着色器之间的区别。

URP 内置着色器

既然我们已经了解了 URP 与其他管线之间的区别,让我们来讨论哪些着色器被集成到 URP 中。让我们简要描述这个管线中三个最重要的着色器:

  • Lit: 这是旧版标准着色器的替代品。这个着色器适用于创建各种逼真的物理材质,例如木材、橡胶、金属、皮肤以及它们的组合(例如带有皮肤和金属护甲的角色)。它支持诸如法线贴图、遮挡、金属和镜面等不同的光照工作流程以及透明度等功能。

  • Simple Lit: 这是旧版 Mobile/Diffuse 着色器的替代品。正如其名所示,这个着色器是 Lit 的一个简化版本,意味着它的光照计算是对光照工作原理的简单近似,比其对应版本具有更少的功能。基本上,当你有简单的图形而没有逼真的光照效果时,这是最佳选择。

  • Unlit: 这是旧版 Unlit/Texture 着色器的替代品。有时,你需要完全没有光照的对象,在这种情况下,这个着色器就是为你准备的。没有光照并不意味着没有光或完全黑暗;实际上,这意味着对象完全没有阴影,并且在没有阴影的情况下完全可见。一些简单的图形可以使用这个着色器,依靠纹理中烘焙的阴影,这意味着纹理自带阴影。

这在性能上非常出色,尤其是对于低端设备,如手机。还有其他情况,例如光管或屏幕,这些不能接收阴影的对象因为它们会发光,所以即使在完全黑暗中,它们也会以全色显示。在下面的屏幕截图中,你可以看到一个使用 Unlit 着色器的 3D 模型。它看起来像是有光照,但实际上只是模型纹理在不同部分应用了浅色和深色颜色:

包含设计描述的图片自动生成

图 10.5:使用 Unlit 效果模拟廉价光照的 Pod

让我们使用 Simple Lit 着色器做一个有趣的分解效果,以展示其功能。你必须执行以下操作:

  1. 首先获取一个Cloud Noise纹理。你可以在各种免费资产网站上找到合适的纹理。确保纹理的分辨率和格式与 Unity 兼容,以获得最佳效果:

云噪声纹理的结果图像

图 10.6:噪声纹理

  1. 项目面板中选择最近导入的纹理。

  2. 在检查器中,将Alpha 源属性设置为从灰度。这将使纹理的 alpha 通道根据图像的灰度进行计算。我们将使用计算出的 alpha 值来确定哪些像素需要首先分解(首先是较暗的像素):计算机截图  描述自动生成,置信度中等

    图 10.7:应用 Alpha 源从灰度纹理设置

    颜色的 alpha 通道通常与透明度相关联,但你会注意到我们的物体不会是透明的。alpha 通道是额外的颜色数据,在创建效果时可以用于多个目的。在这种情况下,我们将使用它来确定哪些像素首先被分解。

  3. 项目视图中点击+图标,并选择材质。你可以重命名它,给它一个更容易找到的名字:

图 10.8:材质创建按钮

  1. 通过顶部菜单选择GameObject | 3D Object | Cube来创建一个立方体:

图 10.9:创建立方体原语

  1. 将材质从项目窗口拖动到场景窗口中的立方体上。

  2. 在检查器中Shader属性右侧的下拉菜单中点击,查找Universal Render Pipeline | Simple Lit选项。我们也可以使用默认的着色器(Lit),但Simple Lit对性能的影响更小,我们也不需要Lit的高级功能:

图 10.10:简单 Lit 着色器选择

  1. 接下来,选择项目中新建的材质。将下载的Cloud Noise纹理拖放到基础贴图部分。这一步将纹理视觉上绑定到着色器上,启用分解效果。

  2. 启用Alpha 裁剪选项,并将阈值滑块调整到0.5Alpha 裁剪在着色器解释纹理透明度方面起着关键作用,影响着分解效果的显示。

图 10.11:Alpha 裁剪阈值材质滑块

  1. 当你移动阈值滑块时,物体将开始分解。Alpha 裁剪会丢弃 alpha 强度低于阈值值的像素:

黑白立方体  描述自动生成,置信度中等

图 10.12:使用 Alpha 裁剪的分解效果

  1. 最后,将渲染面设置为两面以查看立方体面的两侧:

图 10.13:双面渲染面

  1. 请注意,创建纹理的艺术家可以手动配置Alpha通道,而不是从灰度值计算它,以便精确控制分解效果的外观,无论纹理的颜色分布如何:

黑白立方体  使用中等置信度自动生成的描述

图 10.14:双面 Alpha 裁剪

本节的目的不是提供所有 URP 着色器的所有属性的全面指南,而是给你一个概念,了解当着色器被正确配置时它能做什么,以及何时使用每个内置的着色器。有时候,你只需使用现有的着色器就能达到所需的效果,在简单游戏中,这可能是 99%的情况,所以尽可能多地坚持使用它们。但如果确实需要创建一个自定义着色器来创建一个非常具体的效果,下一节将教你如何使用 URP 工具——着色器图。

使用着色器图创建着色器

现在我们已经了解了着色器的工作原理以及 URP 中的现有着色器,我们对何时需要创建自定义着色器以及何时不需要有了基本的概念。如果你真的需要创建一个,本节将涵盖使用着色器图创建效果的基础,着色器图是一个使用基于节点的可视化编辑器创建效果的工具。当你不习惯编码时,这是一个易于使用的工具。

在本节中,我们将讨论以下着色器图的概念:

  • 创建我们的第一个着色器图

  • 使用纹理

  • 合并纹理

  • 应用透明度

  • 创建顶点效果

让我们先看看我们如何创建和使用一个着色器图。

创建我们的第一个着色器图

着色器图是一个允许我们使用基于节点的系统创建自定义效果的工具。在着色器图中,效果可以看起来像以下截图:

计算机截图  自动生成的描述

图 10.15:带有节点以创建自定义效果的着色器图

我们将在稍后讨论这些节点的作用,并将逐步创建一个示例效果,但在截图上,你可以看到我们如何创建和连接几个节点——相互连接的盒子,每个节点执行特定的过程以实现效果。使用着色器图创建效果的想法是学习你需要哪些特定的节点以及如何正确地连接它们。这与我们编写游戏玩法代码的方式类似,但这个着色器图是为了效果目的而调整和简化的。

要创建和编辑我们的第一个着色器图,请执行以下操作:

  1. 项目窗口中,点击+图标,找到Shader Graph | URP | Lit Shader Graph选项。这将创建一个使用 PBR 模式的着色器图,这意味着这个着色器将支持光照效果(与 Unlit 图不同):

图 10.16:PBR 着色器图创建

  1. 将其命名为Water。如果你想有机会重命名资产,请记住你可以选择资产,右键单击,并选择重命名

包含截图、符号、标志、电蓝色的图片,自动生成描述

图 10.17:着色器图资产

  1. 创建一个名为WaterMaterial的新材质,并将着色器设置为Shader Graphs/Water。如果由于某种原因 Unity 不允许你这样做,尝试在Water图上右键单击并单击重新导入。正如你所见,创建的着色器图现在作为着色器出现在材质中:

计算机屏幕截图,自动生成描述,置信度低

图 10.18:设置 Shader Graph 作为材质着色器

  1. 通过在层次结构窗口中右键单击,使用GameObject | 3D Object | Plane选项创建一个平面。

  2. 将材质拖到平面上以应用它。

现在,你已经创建了自己的第一个自定义着色器并将其应用于材质。到目前为止,它看起来一点也不有趣——它只是一个灰色效果——但现在是你编辑图以解锁其全部潜力的时候了。正如图的名称所暗示的,在本章中我们将创建一个水效果来展示 Shader Graph 工具集的几个节点以及如何连接它们。所以,让我们先从讨论主节点开始。

当你通过双击着色器资产打开图时,你会看到以下内容:

图 10.19:包含计算对象外观所需所有属性的 Master 节点

所有节点都将具有输入引脚,所需工作的数据,以及输出引脚,其处理的结果。例如,在求和操作中,我们将有两个输入数字和一个输出数字,即求和的结果。在这种情况下,你可以看到主节点只包含输入,这是因为所有进入主节点的数据都将被 Unity 用于计算对象的渲染和照明,例如所需的对象颜色或纹理(基础颜色输入引脚)、其平滑度(平滑度输入引脚)或其含有的金属量(金属输入引脚),这些属性将影响照明如何应用于对象。

你可以看到主节点被分为顶点部分和片段部分。前者能够改变我们正在修改的对象的网格以变形它、动画化它等,而后者将改变其外观,使用哪些纹理,如何照明等。让我们从以下操作开始探索如何在片段部分更改这些数据:

  1. 项目视图中双击Shader Graph资产以打开其编辑器。

  2. 基础颜色输入引脚左侧的灰色矩形中单击:

包含文本、截图、字体、设计的图片,自动生成描述

图 10.20:基础颜色节点输入引脚

  1. 在颜色选择器中,选择一种浅蓝色,就像水一样。选择圆圈的蓝色部分,然后在中间矩形中选择该颜色的一个色调:

手机截图 描述自动生成,低置信度

图 10.21:颜色选择器

  1. 平滑度设置为0.9,这将使物体几乎完全光滑(达到总平滑度的 90%)。这将使我们的水面几乎完全反射天空:

包含文本、截图、字体、设计的图片 描述自动生成

图 10.22:平滑度 PBR 主节点输入引脚

  1. 点击窗口左上角的保存资产按钮:

计算机屏幕截图 描述自动生成,低置信度

图 10.23:Shader Graph 保存选项

  1. 返回到场景视图,检查平面是否呈现浅蓝色,并且太阳反射在其上:

视频游戏截图 描述自动生成,中等置信度

图 10.24:初始 Shader Graph 结果

如您所见,着色器的行为根据您在主节点中设置的属性而变化,但到目前为止,这样做与创建一个无光照着色器并设置其属性没有区别;Shader Graph 的真正威力在于您使用节点作为主节点的输入进行特定计算时。我们将开始查看纹理节点,这些节点允许我们将纹理应用到我们的模型上。

使用纹理

使用纹理的想法是将图像应用到模型上,以便我们可以用不同的颜色为模型的各个部分上色。请记住,模型有一个 UV 贴图,这使得 Unity 能够知道纹理的哪一部分将被应用到模型的哪一部分:

图 10.25:左侧为面部纹理;右侧为应用于面部网格的相同纹理

就像在视觉脚本中一样,我们将在着色器图中使用相互连接的节点来执行特定的着色器操作。我们有几个节点来完成这项任务,其中之一是Sample Texture 2D,这是一个有两个主要输入的节点。首先,它要求我们提供要采样或应用到模型上的纹理,然后是 UV。您可以在以下屏幕截图中看到它:

计算机截图 描述自动生成,中等置信度

图 10.26:样本 2D 纹理节点

如您所见,纹理输入节点的默认值是None,因此默认情况下没有纹理,我们需要手动指定。对于UV,默认值是UV0,这意味着默认情况下,节点将使用模型的主要 UV 通道;是的,一个模型可以设置多个 UV。目前,我们将坚持使用主要的一个。如果您不确定这意味着什么,UV0是最佳选择。让我们尝试这个节点,按照以下步骤进行:

  1. 从互联网下载并导入一个可平铺的水纹理:

可平铺水纹理的图像结果

图 10.27:可平铺的水纹理

  1. 选择纹理并确保纹理的包裹模式属性设置为重复,这将允许我们像在地形中那样重复纹理,因为我们的想法是使用这个着色器来覆盖大面积的水域:

包含文本、字体、截图、白色的图片,自动生成描述

图 10.28:纹理重复模式

  1. 水着色器图中,在着色器图的一个空白区域右键单击并选择创建节点

包含文本、字体、截图、图形的图片,自动生成描述

图 10.29:着色器图创建节点选项

  1. 搜索框中,键入Sample texture,所有采样节点都会显示出来。双击SampleTexture2D。如果由于某种原因无法双击选项,请先右键单击它,然后再尝试。这个工具中有一个已知的错误,这是解决方案:

计算机截图,自动生成描述,低置信度

图 10.30:纹理节点搜索示例

  1. 点击Sample Texture 2D节点的纹理输入引脚左侧的圆圈。这将允许我们选择要采样的纹理——只需选择纹理。您可以看到纹理可以在节点的底部部分预览:

计算机截图,自动生成描述

图 10.31:具有纹理输入引脚的纹理节点示例

  1. Sample Texture 2D节点的RGBA输出引脚拖到主节点的基色输入引脚:

计算机截图,自动生成描述,中等置信度

图 10.32:将纹理采样结果连接到主节点的基色引脚

  1. 在着色器图编辑器的左上角点击保存资产按钮,并在场景视图中查看更改:

图 10.33:在 Shader Graph 中应用纹理的结果

如您所见,纹理已正确应用于模型,但如果考虑到默认平面的尺寸为 10x10 米,水的波纹看起来太大。所以,让我们平铺纹理吧!

要做到这一点,我们需要更改模型的 UV,使它们更大。您可能会想象更大的 UV 意味着纹理也应该更大,但请记住,我们并没有使对象变大;我们只是在修改 UV。

在相同对象区域,我们将显示更多纹理区域,这意味着在更大的纹理采样区域(通过更大的 UV 实现),纹理可能会重复。要这样做,请按照以下步骤操作:

  1. 在任何空白区域右键单击并点击新建节点以搜索UV节点:

计算机屏幕截图  自动生成的描述,中等置信度

图 10.34:搜索 UV 节点

  1. 使用相同的方法创建一个乘法节点

  2. UV 节点Out引脚拖动到乘法节点A引脚以连接它们。

  3. 乘法B引脚输入值设置为4,4,4,4

包含屏幕截图的图片  自动生成的描述

图 10.35:将 UV 乘以 4

  1. 乘法节点Out引脚拖动到样本纹理 2D 节点UV上以连接它们:

计算机屏幕截图  自动生成的描述,中等置信度

图 10.36:使用乘法 UV 进行纹理采样

  1. 如果您保存图表并返回到场景视图,您会看到现在波纹更小,因为我们已经平铺了模型的 UV。您也可以在采样纹理 2D 节点的预览中看到:

包含屏幕截图、青绿色、蓝绿色、设计的图片  自动生成的描述

图 10.37:模型 UV 乘法的结果

现在我们可以做的另一个有趣的效果是给纹理应用偏移以移动它。想法是即使平面实际上没有移动,我们也会模拟水流通过它,只是移动纹理。记住,确定将纹理的哪一部分应用到模型的每一部分的职责属于 UV,所以如果我们向 UV 坐标添加值,我们就会移动它们,生成纹理滑动效果。为此,让我们做以下操作:

  1. UV 节点的右侧创建一个加法节点

  2. UVOut引脚连接到加法节点A引脚:

计算机屏幕截图  自动生成的描述,低置信度

图 10.38:向 UV 添加值

  1. 加法节点的左侧创建一个时间节点

  2. 时间节点连接到加法节点B引脚:

计算机屏幕截图  自动生成的描述,中等置信度

图 10.39:向 UV 添加时间

  1. 加法节点Out引脚连接到乘法节点A输入引脚:

计算机程序屏幕截图  自动生成的描述,中等置信度

图 10.40:将添加和乘法 UV 作为样本纹理的输入

  1. 保存并查看在场景视图中的水流动。如果您看不到它在移动,请点击场景顶部栏中的图层图标并检查始终刷新

计算机屏幕截图  自动生成的描述

图 10.41:启用始终刷新以预览效果

  1. 如果您觉得水移动得太快,可以尝试使用乘法节点来使时间值更小。我建议您在查看下一个截图之前自己尝试一下,该截图有答案:

计算机屏幕截图  描述自动生成,置信度中等

图 10.42:减慢移动纹理的时间

  1. 如果您觉得图表太大,可以尝试通过点击鼠标悬停在预览上时出现的向上箭头()来隐藏一些节点预览:

计算机屏幕截图  描述自动生成,置信度中等

图 10.43:隐藏图节点中的预览

  1. 此外,您可以通过选择节点并点击其右上角的箭头来隐藏未使用的引脚:

包含截图、文本、字体、数字的图片  描述自动生成

图 10.44:隐藏图节点中的未使用引脚

因此,总结一下,我们首先将时间添加到 UV 以移动它,然后将移动的 UV 的结果相乘以使其变大,从而平铺纹理。值得一提的是,有一个平铺和偏移节点可以为我们完成所有这些过程,但我想要向您展示一个简单的乘法来缩放 UV 和一个加法操作来移动它会产生一个很好的效果;您无法想象您可以用其他简单的数学节点实现的所有可能效果!实际上,让我们在下一节中探索数学节点组合纹理的其他用法。

在学习 DirectX 时,由于您需要学习一个不太友好的语言 HLSL,制作着色器会更困难。虽然对于大多数情况 Shader Graph 就足够了,但我一点也不后悔学习这样的着色器语言,因为它们可以访问基于节点的着色语言通常不具备的更高级功能,并且它们能给您更深入地了解 GPU 内部结构。有关如何在 URP 中创建基于代码的着色器的更多信息,您可以查看以下链接:docs.unity3d.com/Packages/com.unity.render-pipelines.universal@15.0/manual/writing-custom-shaders-urp.html

组合纹理

尽管我们使用了节点,但我们还没有创建出任何不能使用常规着色器创建的东西,但这种情况即将改变。到目前为止,我们可以看到水在移动,但它仍然看起来很静止,这是因为波纹总是相同的。我们有几种生成波纹的技术;最简单的一种是将两个在不同方向上移动的水纹理组合起来以混合它们的波纹。实际上,我们可以简单地使用相同的纹理,只需翻转一下以节省一些内存。为了组合纹理,我们将它们相加,然后除以 2,所以基本上,我们是在计算纹理的平均值!让我们通过以下步骤来完成它:

  1. 通过在图表中的任何空白区域点击,按住并拖动点击,当所有目标节点都被覆盖时释放,选择时间2D 采样器之间的所有节点(包括它们):

计算机截图  描述自动生成,置信度中等

图 10.45:选择多个节点

  1. 右键点击并选择复制,然后再次右键点击并选择粘贴,或者使用经典的Ctrl + CCtrl + V命令(在 Mac 上为Command + CCommand + V)。

  2. 将复制的节点移动到原始节点下方:

计算机截图  描述自动生成

图 10.46:节点的复制

  1. 对于复制的节点,将连接到2D 采样纹理乘法节点的B引脚设置为-4,-4,-4,-4。你可以看到这翻转了纹理。

  2. 此外,将连接到时间节点的乘法节点的B引脚设置为-0.1

计算机截图  描述自动生成

图 10.47:值的乘法

  1. 在两个2D 采样器节点的右侧创建一个加法节点,并将这些节点的输出连接到加法节点的AB输入引脚:

计算机截图  描述自动生成,置信度中等

图 10.48:添加两种纹理

  1. 你可以看到,由于我们同时计算了两种纹理的强度,所以结果组合太亮了,所以让我们通过将加法节点的Out乘以0.5,0.5,0.5,0.5来修复这个问题,这将把每个结果颜色通道除以 2,平均颜色。你也可以尝试设置每个通道的不同值,看看会发生什么,但就我们的目的而言,0.5是每个通道的正确值:

包含截图、文本、多媒体软件、行的图片  描述自动生成

图 10.49:将两个纹理的总和除以得到平均值

  1. 乘法节点的Out引脚连接到主节点基础颜色引脚,以将所有这些计算应用于物体的颜色。

  2. 保存资产,并在场景视图中查看结果:

图 10.50:纹理混合的结果

您可以继续添加节点以使效果更加多样化,例如使用正弦节点(这将执行三角函数的正弦运算)来应用非线性运动,但我会让您通过自己实验来学习这一点。现在,我们将在这里停止。像往常一样,这个主题值得一本完整的书,而本章的目的是给您这个强大的 Unity 工具的小小品尝。我建议您在网上寻找其他 Shader Graph 示例来学习相同节点的其他用法,当然,还有新节点。在这里要考虑的一点是,我们迄今为止所做的一切基本上都是应用于我们之前讨论过的着色器管道的片段着色器阶段。现在,让我们使用混合着色器管道阶段来给水应用一些透明度。

想要了解更多关于着色器图样的示例,我建议查看以下链接:docs.unity3d.com/Packages/com.unity.shadergraph@17.0/manual/ShaderGraph-Samples.html

应用透明度

在宣布我们的效果完成之前,我们可以做的一个小补充是使水稍微透明一些。记住,着色器管道有一个混合阶段,该阶段负责将我们的模型中的每个像素混合到当前帧正在渲染的图像中。我们的想法是让我们的 Shader Graph 修改这个阶段以应用Alpha 混合,这是一种基于我们模型Alpha值的混合模式。

要达到这种效果,请按照以下步骤操作:

  1. 寻找浮动在周围的图形检查器窗口。如果您看不到它,请点击 Shader Graph 编辑器右上角的图形检查器按钮。也可以尝试展开 Shader Graph 窗口以显示它,以防它被窗口的右边界隐藏。

  2. 点击图形设置选项卡。

  3. 表面类型属性设置为透明

  4. 如果混合模式属性尚未设置为该值,请将其设置为Alpha

计算机屏幕截图  描述自动生成,置信度中等

图 10.51:图形检查器透明度设置

  1. 将主节点的Alpha输入引脚设置为0.5

计算机屏幕截图  描述自动生成,置信度低

图 10.52:设置主节点的 Alpha

  1. 保存 Shader Graph 并查看在场景视图中应用的透明度。如果您看不到效果,只需将一个立方体放入水中,使效果更加明显:

黑白立方体在瓷砖地板上  描述自动生成,置信度低

图 10.53:将水产生的阴影应用于立方体

  1. 你可以看到水在我们立方体上投射的阴影,因为 Unity 不知道该对象是透明的,因此会投射阴影。点击水面,在检查器中查找 网格渲染器 组件。如果你看不到阴影,点击 场景 视图顶部的灯泡。

计算机屏幕截图,自动生成的低置信度描述

图 10.54:在场景视图中启用灯光

  1. 照明 部分中,将 投射阴影 设置为 关闭;这将禁用在水下部分的立方体上从平面上投射阴影:

图 10.55:禁用阴影投射

添加透明度是一个简单的过程,但它有其注意事项,比如阴影问题,在更复杂的情况下,它可能还有其他问题,比如增加过度绘制,意味着同一个像素需要绘制多次(属于透明对象的像素,以及背后物体中的一个)。我建议你在不必要的情况下避免使用透明度。实际上,我们的水可以不透明地存在,尤其是当我们将这种水应用到基础周围的河盆时,因为我们不需要看到水下部分;但目的是让你知道所有选项。在下一张截图,你可以看到我们如何在基础下方放置了一个带有这种效果的大型平面,足够大,可以覆盖整个盆地:

包含草的图片,截图,自动生成的描述

图 10.56:在主场景中使用我们的水

我有一个朋友总是拿我开玩笑,说所有问题都可以用着色器解决,但玩笑归玩笑,如果使用得当,它是一个非常实用的工具。在过去,开发者使用着色器进行与图形无关的处理,比如数学模拟,读取生成的像素作为所需的结果。这导致了今天我们所知的计算着色器,它本质上是指在 GPU 上运行自定义程序以进行计算,利用 GPU 的强大功能。当然,Unity 支持计算着色器;你可以在这里了解更多信息:docs.unity3d.com/Manual/class-ComputeShader.html

从现在起,我们可以用我们的着色器做很多事情。我们可以考虑为高于一定高度像素模拟水泡,利用我们添加的顶点动画。我们也可以通过脚本或使用正弦节点来改变水的滚动方向。天空才是极限!

现在我们已经通过 片段 节点部分修改了对象的外观,让我们讨论如何使用 顶点 节点来将网格动画应用到我们的水上。

创建顶点效果

到目前为止,我们已经将水纹理应用到我们的水上,但它仍然是一个平面。我们可以更进一步,不仅通过纹理,还可以通过动画网格来制作波纹。要做到这一点,我们将在着色器中应用我们在本章开头使用的噪声纹理,但不是将其用作添加到着色器基础颜色的另一种颜色,而是用它来偏移我们平面的顶点的Y位置。

由于噪声纹理的混沌性质,我们的想法是对模型的各个部分应用垂直偏移,这样我们就可以模拟波纹:

包含线条、对称、正方形、黑白图案的图片  自动生成的描述

图 10.57:默认平面网格细分为 10x10 的网格,无偏移

要实现类似的效果,您可以修改着色器的顶点部分,使其看起来如下:

计算机屏幕截图  自动生成的描述

图 10.58:波纹顶点效果

在图中,您可以看到我们如何创建一个向量,其y轴取决于我们在本章开头下载的噪声纹理。背后的想法是创建一个向上指的向量,其长度与纹理的灰度因子成正比;纹理的像素越白,偏移量越长。这种纹理具有不规则但平滑的图案,可以模拟潮汐的行为。

请注意,在这里,我们使用了采样 2D 纹理 LOD而不是采样 2D 纹理;后者在顶点部分不起作用,所以请记住这一点。

然后,我们将结果乘以0.3以减少要添加的偏移高度,然后将结果添加到位置节点。注意,位置节点的空间属性设置为对象模式。我们需要这种模式来与着色器图(我们在第二章制作场景和游戏元素中讨论了世界局部空间,但您也可以在网上搜索Object vs World Space以获取更多关于此的信息)。最后,结果连接到顶点部分的位置节点。

如果你保存,你会看到以下类似图像:

包含青绿色、绿松石色、图案、蓝绿色的图片  自动生成的描述

图 10.59:应用波纹顶点效果

当然,在这种情况下,波纹是静态的,因为我们没有像之前那样对 UV 添加任何时间偏移。在下面的屏幕截图中,您可以查看如何添加它,但在查看之前,我建议您先自己尝试解决它作为一个个人挑战:

计算机屏幕截图  自动生成的描述

图 10.60:动画波纹顶点效果图

正如你所见,我们再次使用原始的 UV,并添加任何因子的乘以时间,这样它就会慢慢移动,就像我们之前在水面纹理上所做的那样。你可以继续尝试不同的纹理,改变其外观,通过乘以偏移量来增加或减少波纹的高度,应用如正弦等有趣的数学函数,以及更多;但现在,让我们先完成这个。

摘要

在本章中,我们讨论了着色器在 GPU 中的工作原理以及如何创建我们的第一个简单着色器以实现良好的水面效果。与着色器一起工作是一项复杂而有趣的工作,在一个团队中,通常有一人或多人负责创建所有这些效果,这个职位被称为技术艺术家;所以,正如你所见,这个主题可以扩展成一个完整的职业。记住,这本书的目的是给你一个行业所有可能角色的微小品尝,所以如果你真的喜欢这个角色,我建议你开始阅读专注于着色器的书籍。你面前有一条漫长但超级有趣的道路。

现在足够多的着色器了!在下一章中,我们将探讨如何通过粒子系统改进我们的图形并创建视觉效果!

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

第十一章:吸引人的视觉效果:利用粒子系统和视觉效果图

在本章中,我们将继续学习我们游戏中的视觉效果。我们将讨论粒子系统,这是一种模拟火焰、瀑布、烟雾和各种流体的方法。我们将看到两个 Unity 粒子系统来创建这些效果,Shuriken视觉效果(VFX)图,后者比前者更强大,但需要更多的硬件。这些技术在不同的游戏中被广泛使用,以模拟各种效果,如战场等战争游戏中的爆炸和枪口效果,以及在霍格沃茨遗产中的魔法效果。

在本章中,我们将介绍以下粒子系统主题:

  • 粒子系统简介

  • 创建流体模拟

  • 使用视觉效果图创建复杂模拟

粒子系统简介

我们迄今为止创建的所有图形和效果都使用了静态网格——不能通过任何方式(除了使用着色器)扭曲、弯曲或变形的 3D 模型。例如,火焰和烟雾这类流体不能总是用这种网格来表示,但我们可以通过静态网格的组合来模拟这些效果,这就是粒子系统发挥作用的地方。

粒子系统是发射和动画化大量粒子标牌的对象,这些是面向摄像机的简单四边形网格。每个粒子都是一个静态网格,但渲染、动画和组合大量粒子可以产生流体的幻觉。

在图 11.1 中,你可以看到左侧使用粒子系统创建的烟雾效果,右侧是相同粒子的线框视图。在那里,你可以看到创建烟雾幻觉的四边形,这是通过将烟雾纹理应用到每个粒子并对其动画化来实现的,使它们从底部生成并随机向上移动:

图 11.1:左侧是一个烟雾粒子系统;右侧是相同系统的线框

在本节中,我们将介绍与粒子相关的以下主题:

  • 创建基本粒子系统

  • 使用高级模块

让我们从讨论如何创建我们第一个粒子系统开始。

创建基本粒子系统

为了说明粒子系统的创建,让我们创建一个爆炸效果。想法是同时生成大量粒子并向所有方向扩散。让我们从创建粒子系统并配置它提供的基本设置以改变其默认行为开始。为此,请按照以下步骤操作:

  1. 选择 GameObject | Effects | Particle System 选项:

图 11.2:粒子系统选项

  1. 你可以在下面的屏幕截图中看到效果。默认行为是一列向上移动的粒子,就像之前显示的烟雾效果。让我们改变一下。

图 11.3:默认粒子系统外观

  1. 在场景中单击创建的对象,并查看检查器。

  2. 通过点击标题打开形状部分。在这里,您可以指定粒子发射器的形状,粒子将从该形状中产生。

  3. 形状属性更改为球体。现在粒子应该会向所有可能的方向移动,而不是遵循默认方向:

图 11.4:形状属性

  1. 粒子系统模块(通常称为)中,将起始速度设置为10。这将使粒子移动得更快。

  2. 在同一模块中,将起始寿命设置为0.5。这指定了粒子将存活多长时间。在这种情况下,我们给了一个半秒的寿命。结合速度(每秒 10 米),这意味着粒子在移动 5 米后会消失:

图 11.5:主粒子系统模块

  1. 打开发射模块,并将随时间变化率设置为0。此属性指定每秒将发射多少粒子,但对于爆炸,我们实际上需要一个粒子爆发,所以在这种情况下我们不会在随时间持续发射粒子。

  2. 爆发列表中,点击底部的+按钮,并在列表中创建的项目中,将数量列设置为100

图 11.6:发射模块

  1. 模块(标题为粒子系统)中,将持续时间设置为1并取消选中循环。在我们的情况下,爆炸不会不断重复;我们只需要一次爆炸:

图 11.7:循环复选框

  1. 现在我们已经在场景中有了粒子,我们注意到它没有循环。为了实现这种效果,您需要手动点击场景视图右下角的粒子效果窗口中显示的播放按钮来查看系统。如果您没有看到该窗口,请记住首先在层次结构窗口中选择带有粒子系统的 GameObject。

图 11.8:粒子系统播放控制

  1. 停止动作设置为销毁。这将使对象在持续时间时间过后被销毁。这仅在运行游戏时才会起作用,因此您可以在编辑场景时安全地使用此配置:

图 11.9:停止动作设置为销毁

  1. 模块的起始大小设置为3。这将使粒子更大,看起来更密集:

图 11.10:粒子系统起始大小

  1. 点击主模块中起始旋转属性右侧的向下箭头,并选择在两个常量之间随机

  2. 步骤 14之后出现的两个输入值中将起始旋转设置为0360。这允许我们在粒子产生时给它们一个随机旋转,使它们看起来略有不同:

图 11.11:随机起始旋转

  1. 现在粒子表现如预期,但看起来并不如预期。让我们改变一下。通过在项目视图中点击+图标并选择材质来创建一个新的材质。让我们称它为Explosion

  2. 将其着色器设置为通用渲染管线/粒子/无光照。这是一个特殊的着色器,用于将纹理应用到粒子系统中:

图片

图 11.12:粒子系统材质着色器

  1. 从互联网或资源商店下载一个烟雾粒子纹理。在这种情况下,下载一个黑色背景的纹理很重要;忽略其他纹理:

图片

图 11.13:烟雾粒子纹理

  1. 将此纹理设置为材质的基础纹理

  2. 表面类型设置为透明,并将混合模式设置为添加。这样做会使粒子相互融合,而不是相互绘制,以模拟一大团烟雾而不是单个烟雾团。我们使用添加模式,因为我们的纹理有黑色背景,我们想要创建一个光照效果(爆炸会使场景变亮):

图片

图 11.14:粒子的表面选项

  1. 将您的材质拖到渲染器模块的材质属性中:

图片

图 11.15:粒子材质设置

  1. 现在您的系统应该看起来像以下图所示:

图片

图 11.16:前述设置的成果

通过这些步骤,我们已经改变了粒子或广告牌的生成方式(使用发射模块),它们将移动的方向(使用形状模块),它们的移动速度,它们的持续时间,它们的大小(使用模块),以及它们的形状(使用渲染器模块)。创建粒子系统是一个简单案例,只需正确配置它们的设置。当然,正确地做这件事本身就是一门艺术;它需要创造力和了解如何使用所有提供的设置和配置。因此,为了提高我们的技能,让我们讨论一些高级模块。

使用高级模块

我们的系统看起来不错,但我们还可以大幅改进它,所以让我们启用一些新模块来提高其质量:

  1. 生命周期颜色左侧的复选框中勾选以启用它:

图片

图 11.17:启用生命周期颜色模块

  1. 通过点击标题打开模块,然后点击颜色属性右侧的白色条,这将打开渐变编辑器。

  2. 在条形图的最左上角白色标记的稍右位置点击以创建一个新的标记。同样,在条形图最右上角白色标记的稍左位置点击以创建第四个标记。这些标记将允许我们在粒子的生命周期中指定其透明度:

图片

图 11.18:生命周期颜色渐变编辑器

  1. 如果您意外创建了不需要的标记,只需将它们拖出窗口即可删除它们。确保模式设置为混合(经典)

  2. 点击左上角的标记(不是我们创建的,而是已经存在的那个)并将底部的Alpha滑块设置为0。按照以下截图所示,以同样的方式对右上角的标记进行操作。现在您应该会看到粒子在爆炸结束时逐渐消失而不是突然消失:

图片

图 11.19:淡入和淡出渐变

  1. 通过点击复选框启用寿命内限制速度模块。

  2. 阻尼设置设置为0.1。这将使粒子缓慢停止而不是继续移动:

图片

图 11.20:阻尼速度以使粒子停止

  1. 启用寿命内旋转并将角速度设置为-9090之间。请记住,您应该通过点击属性右侧的向下箭头将值设置为在两个常量之间随机。现在粒子应该在它们的寿命期间旋转以模拟更多的运动:

图片

图 11.21:随机旋转速度

由于我们在创建粒子时在模块中设置的寿命较短,因此一些效果将非常微妙。您可以自由地增加寿命值以更详细地查看这些效果,但请考虑,如果您频繁地生成粒子,这可能会导致粒子数量过多,从而降低性能。只需注意调整这些值时它们对性能的影响。

如您所见,有许多额外的模块可以启用和禁用,以在现有行为之上添加层次。因此,再次创造性地使用它们来创建各种效果。请记住,您可以为这些系统创建预制件以在场景中复制它们。我还建议在资产商店中搜索和下载粒子效果,以了解其他人如何使用相同的系统创建惊人的效果。看到各种不同的系统是学习如何创建它们的最佳方式,这就是我们在下一节将要做的:创建更多系统!

创建流体模拟

正如我们所说的,学习如何创建粒子系统的最佳方式是持续寻找已经创建的粒子系统,并探索人们如何使用各种系统设置来创建完全不同的模拟。

在本节中,我们将学习如何使用粒子系统创建以下效果:

  • 水帘效果

  • 篝火效果

让我们从最简单的一个开始,即水帘效果。

创建水帘效果

为了做到这一点,请按照以下步骤操作:

  1. 创建一个新的粒子系统(GameObject | Effects | Particle System)。

  2. 形状模块中将形状设置为边缘并将半径设置为5。这将使粒子沿着发射线生成:

图片

图 11.22:边缘形状

  1. Emission 模块的 Rate over Lifetime 值设置为 50

  2. Main 模块的 Start Size 设置为 3Start Lifetime 设置为 3

图 11.23:主模块设置

  1. Main 模块的 Gravity Modifier 设置为 0.5。这将使粒子下落:

图 11.24:主模块中的重力修改器

  1. 使用我们之前创建的相同 Explosion 材质来创建这个系统:

图 11.25:爆炸粒子材质

  1. 启用 Color over Lifetime 并打开渐变编辑器。确保 Mode 设置为 Blend (Classic),因为它是最简单且性能最好的一个。

  2. 点击右下角的标记,这次你应该看到一个颜色选择器而不是透明度滑块。顶部的标记允许你随时间改变透明度,而底部的标记则随时间改变粒子的颜色。在这个标记中设置浅蓝色颜色(你也可以使用任何其他颜色):

图 11.26:从白色到浅蓝色的渐变

作为挑战,我建议你在这个粒子系统结束的地方添加一个小粒子系统,以创建一些水花,模拟水与底部湖泊的碰撞。现在我们可以将这个粒子系统添加到场景中的一个山丘上,以装饰它,如下面的截图所示。我已经对这个系统做了一些调整,使其在这个场景中看起来更好。我挑战你自己调整它,使其看起来像这样:

图 11.27:瀑布粒子系统应用于当前场景

现在,让我们创建另一个效果:篝火。

创建篝火效果

为了创建篝火,请执行以下操作:

  1. GameObject | Effects | Particle System 中创建一个粒子系统,就像我们在 创建基本粒子系统 部分所做的那样。

  2. 在互联网或资产商店中寻找一个 Fire Particle Texture Sheet 纹理。这种纹理应该看起来像不同火焰纹理的网格。想法是将火焰动画应用到我们的粒子中,交换所有这些小纹理:

图 11.28:粒子纹理精灵图

  1. 创建一个使用 Universal Render Pipeline/Particles/Unlit 着色器的粒子材质。

  2. 将火焰精灵图纹理设置为基图。

  3. Base Map 右侧的颜色设置为白色。

  4. 将此材质设置为粒子材质。请记住将 Surface Type 设置为 TransparentBlending Mode 设置为 Additive

图 11.29:带有粒子精灵图的材质

  1. 启用 Texture Sheet Animation 模块,并根据你的 Fire 图纸设置 Tiles 属性。在我的情况下,我有一个 4x4 的精灵网格,所以我将 4 放在 X4 放在 Y。之后,你应该会看到粒子在交换纹理:

包含文本、截图、字体、行描述自动生成

图 11.30:启用纹理表动画

  1. 模块中将起始速度设置为0,将起始大小设置为1.5

  2. 形状模块中将半径设置为0.5

  3. 创建第二个粒子系统并将其设置为火系统的子系统:

图 11.31

图 11.31:粒子系统的父子关系

  1. 应用爆炸示例中的爆炸材质。

  2. 形状模块中将角度设置为0,将半径设置为0.5

系统应该看起来像这样:

图 11.30

图 11.32:结合火焰和烟雾粒子系统的结果

如您所见,您可以将多个粒子系统组合起来创建一个单一的效果。在做这件事时要小心,因为很容易产生过多的粒子并影响游戏性能。粒子并不便宜,如果您不小心,可能会降低游戏的每秒帧数FPS)。

粒子之所以昂贵,其中一个原因被称为过度绘制,或者多个重叠对象的情况。记住,粒子只是许多叠加在一起的四边形渲染,以模拟单一流体。这意味着一些屏幕像素需要绘制多次才能达到效果,这可能会对 GPU 产生很大负担。您可以使用 RenderDoc 等工具分析这个问题和其他 GPU 问题。更多关于 RenderDoc 的信息请在这里了解:docs.unity3d.com/Manual/RenderDocIntegration.html

到目前为止,我们已经探索了您可以使用来创建这类效果的一个 Unity 系统,虽然这个系统对于大多数情况来说已经足够,但 Unity 最近发布了一个新的系统,可以生成更复杂的效果,称为视觉效果图。让我们看看如何使用它,并看看这两个系统有何不同。

使用视觉效果图创建复杂模拟

我们迄今为止使用的粒子系统在 CPU 上处理所有计算。这既有优点也有缺点。优点是它可以在所有可能的 Unity 支持的设备上运行,无论它们的性能如何(它们都有 CPU),但缺点是如果我们不小心发射的粒子数量过多,我们很容易超过 CPU 的性能。

现代游戏需要更复杂的粒子系统来生成逼真的效果,而这种基于 CPU 的粒子系统解决方案已经开始达到其极限。这就是视觉效果图(Visual Effect Graph)的用武之地:

图 11.33

图 11.33:左侧是一个庞大的粒子系统,右侧是一个视觉效果图的示例

Visual Effect Graph是一个基于 GPU 的粒子系统解决方案,这意味着系统是在显卡上而不是 CPU 上执行的。这是因为显卡在执行大量的小型模拟方面效率更高,比如系统中的每个粒子都需要,所以我们使用 GPU 可以达到比 CPU 更高的粒子数量级别。这里的缺点是我们需要一个具有计算着色器功能的相当现代的 GPU 来支持这个系统,因此我们将排除使用这个系统的某些目标平台(忘掉大多数手机),所以如果你的目标平台支持它(中高端 PC、游戏机和一些高端手机),请使用它。

在本节中,我们将讨论以下关于 Visual Effect Graph 的主题:

  • 安装 Visual Effect Graph

  • 创建和分析 Visual Effect Graph

  • 创建雨效果

让我们先看看我们如何在项目中添加对 Visual Effect Graph 的支持。

安装 Visual Effect Graph

到目前为止,我们已经使用了在我们项目中已经安装的许多 Unity 功能,但 Unity 可以通过大量的插件进行扩展,包括官方和第三方插件。Visual Effect Graph 就是那些需要独立安装的功能之一,如果你使用的是通用渲染管线URP)。我们可以通过包管理器来实现,这是一个专门用于管理官方 Unity 插件的 Unity 窗口。

当你安装这些包时,需要考虑的是,每个包或插件都有自己的版本,与 Unity 版本无关。这意味着你可以安装 Unity 2022.1,但也可以安装 Visual Effect Graph 13.1.8 或任何你想要的版本,并且实际上可以更新包到新版本而不需要升级 Unity。

这很重要,因为一些这些包的版本需要 Unity 的最小版本——例如,Visual Effect Graph 13.1.8 需要 Unity 2022.1 作为最小版本。此外,一些包依赖于其他包以及这些包的特定版本,因此我们需要确保我们拥有每个包的正确版本,以确保兼容性。明确来说,包的依赖项会自动安装,但有时我们可以单独安装它们,所以在那种情况下,我们需要检查所需的版本。听起来很复杂,但实际上并没有那么复杂。

在撰写本书时,为了使 Visual Effect Graph 正常工作,我们需要版本 15.0.6,并且还需要相同版本的通用 RP。是的,通用 RP 是另一个你可以使用包管理器安装的功能,但因为我们使用的是通用 RP模板创建的项目,所以它已经为我们安装了正确的版本。考虑到这一点,让我们按照以下步骤安装 Visual Effect Graph:

  1. 在 Unity 的顶部菜单中,转到窗口 | 包管理器

图片

图 11.34:包管理器位置

  1. 请确保下拉菜单处于Unity 注册表模式,以查看 Unity 官方包列表:

图 11.35:包管理器 Unity 注册表模式

  1. 在左侧列中,找到Universal RP并检查右侧是否显示 15.0.6 或更高版本。如果是,跳转到步骤 6。不过,请记住,更高版本可能看起来不同,或者使用步骤与本章中显示的不同。

  2. 如果您没有 15.0.6 或更高版本,点击面板右侧的版本历史选项卡以显示所有可能的版本列表。定位到15.0.6并点击它。在我的情况下,它显示为已安装,因为我已经在项目中安装了该版本,并且没有其他版本可用于 Unity 2022:

图 11.36:包版本选择器

  1. 点击窗口右下角的更新到 15.0.6按钮,等待包更新。

  2. 在窗口左侧查找视觉效果图包。与 Universal RP 一样,确保您选择版本15.0.6或更高版本:

图 11.37:视觉效果图包

  1. 点击窗口右下角的安装按钮,等待包安装。有时建议在安装包后重启 Unity,因此请保存您的更改并重启 Unity。

现在我们已经安装了视觉效果图,让我们使用它创建第一个粒子系统。

创建和分析视觉效果图

使用视觉效果图创建粒子系统的方法与常规粒子系统类似。我们将链式配置模块作为粒子行为的一部分,每个模块添加一些特定的行为。首先,我们需要创建视觉效果图,这是一个将包含所有模块和配置的资产,然后创建一个 GameObject 来执行图资产以生成粒子。让我们按照以下步骤进行:

  1. 项目窗口中,点击+按钮,查找视觉效果 | 视觉效果图

图 11.38:视觉效果图

  1. 从新弹出的窗口中,从可用模板中选择简单循环选项。您可以重命名文件为 Rain:

图 11.39:简单循环模板

  1. 使用GameObject | Create Empty选项创建一个空 GameObject:

图 11.40:空 GameObject 创建

  1. 选择创建的对象,查看检查器。

  2. 使用添加组件搜索栏,查找视觉效果组件并点击它以将其添加到对象:

图 11.41:向视觉效果图添加组件

  1. 将我们创建的视觉效果资产拖动到 GameObject 中的视觉效果组件的资产模板属性:

图 11.42:使用之前创建的视觉效果资产

  1. 你应该看到一个代表我们的对象的魔法灯图示,这是新视觉效果资产的默认状态,意味着它被正确创建:

图 11.43 默认视觉效果资产结果

在创建基本视觉效果并拥有基础效果之后,现在让我们创建一些需要大量粒子的东西,比如密集的雨。在这样做之前,我们将探索视觉效果图的一些核心概念。如果你双击视觉效果资产,你会看到以下编辑器:

图 11.44:视觉效果图编辑器窗口

这个窗口由几个相互连接的节点组成,生成要执行的动作流。与 Shader Graph 类似,你可以通过按住Alt键(Mac 上的Option键)并用鼠标拖动图中的空白区域来导航这个窗口。一开始,它看起来与 Shader Graph 相似,但它的工作方式略有不同,所以让我们研究默认图的每个部分。

首先要探索的区域是包含三个节点的虚线区域。这是 Unity 所说的系统。系统是一组节点,它定义了粒子将如何行为,你可以拥有任意多个,这相当于拥有多个粒子系统对象。每个系统由上下文组成,即虚线区域内的节点,在这个例子中,我们有初始化粒子更新粒子输出粒子四边形。每个上下文代表粒子系统逻辑流程的不同阶段,因此让我们定义我们图中的每个上下文的作用:

  • 初始化粒子:这定义了每个发射粒子的初始数据,例如位置、颜色、速度和大小。它类似于我们在本章开头看到的粒子系统的模块中的开始属性。这个节点中的逻辑只有在新的粒子被发射时才会执行。

  • 更新粒子:在这里,我们可以对存活粒子的数据进行修改。我们可以改变粒子数据,如当前速度或所有帧的粒子大小。这类似于我们在本章开头看到的粒子系统的时间流逝节点。

  • 输出粒子四边形:当粒子需要被渲染时,这个上下文将被执行。它将读取粒子数据以确定渲染位置、渲染方式、使用的纹理和颜色以及不同的视觉设置。这类似于之前粒子系统的渲染器模块。

在每个上下文中,除了一些基本配置外,我们还可以添加。每个块都是一个将在上下文中执行的操作。我们有可以在任何上下文中执行的操作,以及一些特定上下文操作。例如,我们可以在初始化粒子上下文中使用添加位置块来移动初始粒子位置,但如果我们在更新粒子上下文中使用相同的块,它将使粒子持续移动。所以基本上,上下文是粒子生命周期中发生的情况,而块是在这些情况下执行的操作:

图 11.45:初始化粒子上下文中的“设置速度随机”块。这设置了粒子的初始速度

此外,我们还可以有独立上下文,即系统之外的上下文,例如生成。这个上下文负责告诉系统需要创建一个新的粒子。我们可以添加块来指定上下文何时告诉系统创建粒子,例如在固定的时间间隔内,爆发等。想法是生成将根据其块创建粒子,而系统则负责根据我们在每个上下文中设置的块初始化、更新和渲染每个粒子。

因此,我们可以看到它与常规粒子系统有很多相似之处,但在这里创建系统的方式相当不同。让我们通过创建一个雨效来加强这一点,这将需要大量的粒子——这是 Visual Effect Graph 的一个很好的用例。

创建雨效

为了创建这个效果,请执行以下操作:

  1. 将“初始化粒子”上下文的容量属性设置为10000

图 11.46 初始化粒子上下文

  1. 在图表的开始处设置常量出生率10000

图 11.47:常量出生率块

在图上添加新节点之前,我们将删除我们不需要创建雨效的节点。

  1. 在“初始化粒子块”中的节点设置位置(形状:圆弧球体)”上右键单击并选择删除**以删除此节点:

图 11.48:删除“设置位置”块

  1. 使用节点“从方向和速度(新方向)设置速度”重复此过程以删除它

图 11.49:删除“设置速度”块

  1. 右键单击“初始化粒子”标题,并选择创建块

  2. 搜索“设置速度随机(按组件)”块并点击它以添加:

图 11.50:添加“设置速度随机”块

  1. 在“初始化粒子”上下文中的“设置速度随机”块中,将 A 和 B 属性分别设置为(0, -50, 0)和(0, -75, 0)。这将为我们设置一个指向下方的随机速度:

图 11.51:设置速度随机块

  1. 右键单击更新粒子方块内的重力节点,并选择删除以移除此节点:

图 11.52:删除重力方块

  1. 使用线性阻力方块重复此过程:

图 11.53:删除线性阻力方块

  1. 在进行这两次删除操作后,更新粒子方块将变为空:

图 11.54:空的更新粒子方块

  1. 右键单击 初始化粒子 标题,并选择 创建方块

  2. 在网络上搜索 Set Position Random (Per-component) 方块,并点击它:

图 11.55:添加方块

  1. Set Position Random 方块的 AB 属性分别设置为 (-50, 0, -50) 和 (50, 0, 50)。这将定义一个初始区域,用于随机生成粒子。

  2. 点击 Initialize Particle 方块的 Bounds 属性左侧的箭头以显示其属性,并将 CenterSize 分别设置为 (0, -12.5, 0) 和 (100, 25, 100)。这将定义粒子应可见的区域。粒子实际上可以移动到这个区域之外,但仅在我们感兴趣的区域中渲染粒子是很重要的。

在网络上搜索 Frustum culling 以获取有关边界的更多信息。

图 11.56:配置方块

  1. 选择执行系统的 GameObject,在 场景 视图的右下角窗口中,勾选 显示边界 复选框以查看之前定义的边界:

图 11.57:视觉效果播放控制

  1. 如果你没有看到右下角的窗口,请点击屏幕左上角的 VE(视觉效果)按钮来显示它。此按钮仅在你在 层次结构 中选择了 视觉效果 GameObject 时才会显示:

图 11.58:另一种显示视觉效果播放控制的方式

  1. 如果你看不到应用的变化,请点击窗口左上角的 编译 按钮,它看起来像箭头下方的纸篓。你也可以使用 Ctrl + S(在 Mac 上为 Command + S)保存你的更改:

图 11.59:VFX 资产保存控制

  1. 将对象位置设置为覆盖整个基础区域。在我的情况下,位置10037,和 100。请记住,你需要更改 Transform 组件的 位置

图 11.60:设置变换位置

  1. Initialize Particle 中将 Set Lifetime Random 方块的 AB 属性设置为 0.5。这将使粒子的寿命更短,确保它们始终在边界内:

图 11.61:设置寿命随机方块

  1. 输出粒子四边形上下文的主纹理属性更改为另一个纹理。在这种情况下,之前下载的烟雾纹理可以在这里使用,即使它不是水,因为我们将在稍后修改其外观。此外,如果您想的话,还可以尝试下载水滴纹理:

图片

图 11.62:VFX 图主纹理

  1. 输出粒子四边形上下文的混合模式设置为添加

包含文本、截图、字体、黑色描述的图片,自动生成

图 11.63:VFX 图的添加模式

  1. 我们需要稍微拉伸我们的粒子,使其看起来像真正的雨滴而不是下落的球体。在完成这一目标之前,首先,我们需要改变粒子的方向,使它们不会总是指向摄像机。为了做到这一点,在输出粒子四边形上下文中的定位块上右键单击并选择删除(或在 PC 上按Delete或在 Mac 上按Command + Backspace):

图片

图 11.64:删除块

  1. 我们希望根据粒子的速度方向拉伸粒子。在实际上进行这一操作之前,另一个准备步骤是选择输出粒子四边形上下文的标题并按空格键查找要添加的块。在这种情况下,我们需要搜索并添加沿速度方向定位块。

  2. 初始化粒子上下文中添加一个设置缩放块,并将缩放属性设置为0.251.50.25。这将使粒子看起来像下落的雨滴:

图片

图 11.65:设置缩放块

  1. 再次单击左上角的编译按钮以查看更改。您的系统应该看起来像这样:

图片

图 11.66:雨效果

  1. 您可以通过 Output Particle Quad Block 中的两个节点调整其余的节点值,以在粒子的整个生命周期内改变粒子的大小和颜色。

我们刚刚修改了视觉效果图的许多不同属性,但如果您想要两个具有轻微差异的相同视觉效果图实例,我建议您查看黑板功能,这将允许您在检查器中公开属性。例如,您可以在另一个场景中制作密度较低的雨,降低生成率,或将粒子颜色改为酸雨,所有这些都可以使用相同的图,但现在让我们保持简单。

黑板功能也存在于着色器图中。

从这里,您可以按照自己的意愿添加和删除上下文中的块进行实验,并且再次,我建议您查找已创建的视觉效果图以获取其他系统的想法。实际上,您可以通过查看常规粒子系统中的效果并使用类似块来获取视觉效果图的灵感。

此外,我建议你在网上或以下链接中搜索视觉效果图文档:docs.unity3d.com/Packages/com.unity.visualeffectgraph@15.0/manual/index.html,以了解更多关于这个系统。你还可以通过在包管理器中选择包时点击文档按钮来访问任何 Unity 包的文档。

![img/B21361_11_67.png]

图 11.67:包管理器文档链接

现在我们已经学会了如何创建不同的视觉效果,让我们看看如何通过脚本使用它们来实现对游戏中发生的事情做出反应的效果。

脚本化视觉效果

视觉反馈是使用不同的视觉效果(如粒子效果和 VFX 图),以强化正在发生的事情的概念。例如,假设我们现在正在开枪;我们知道这是在发生,因为我们能看到子弹。然而,这并不像真实的射击效果,因为一个合适的射击效果应该在枪口处产生枪口效果。另一个例子是敌人死亡——它只是没有动画地消失!这并不像它本可以那样令人满意。我们可以添加一个小爆炸(考虑到它们是机器人)。

让我们从以下步骤开始,让我们的敌人被摧毁时产生爆炸:

  1. 创建一个爆炸效果,或者从资产商店下载一个。它不应该循环,并且在爆炸结束后需要自动销毁(确保在 Main 模块中循环未被勾选,并将停止动作设置为销毁)。你可以自由使用我们在本章中之前使用过的同一个效果。

  2. 资产商店中的一些爆炸效果可能使用与 URP 不兼容的着色器。该包的 Asset Store 页面指定了该包是否支持 URP。你还可以通过使用 Window | Rendering | Render Pipeline Converter 来修复它们,正如我们在 第四章 中所看到的,无缝集成:导入和集成资产

  3. 手动升级那些没有自动升级的材料。

  4. Enemy 预制体上添加一个名为 ExplosionOnDeath 的脚本。这将负责在敌人死亡时生成粒子预制体。

  5. 添加一个名为 particlePrefabGameObject 类型的字段,并将 explosion 预制体拖放到它上面。

    你可能期望将爆炸生成添加到 Life 组件中。在这种情况下,你假设与生命有关的所有内容在死亡时都会生成粒子,但考虑一下这样的场景:角色死亡时带有下落动画,或者可能是一个没有任何效果就消失的对象。如果某种行为在大多数场景中都没有使用,那么最好将其编码在单独的可选脚本中,这样我们可以混合和匹配不同的组件,以获得我们想要的确切行为。

  6. 让脚本访问我们在第八章,胜利或失败:胜利和失败条件 中创建的 Life 组件,并订阅其 OnDeath 事件。

  7. listener 函数中,让我们在相同的位置实例化粒子系统:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 11.68:爆炸生成器脚本

可视脚本版本看起来是这样的:

图 11.69:爆炸生成器可视脚本

如您所见,我们只是在之前章节中学到的相同概念,但以新的方式组合它们。这就是编程的全部内容。

让我们继续讨论枪口效果,这使我们的枪感觉更真实,并为玩家的角色射击提供反馈。它也可以是一个粒子系统,但这次我们将采取另一种方法:

  1. 如果你还没有,从 Asset Store 下载一个武器模型。我们在这本书中使用的包中的角色已经包含了一个,所以我们将使用那个。

  2. 如果你的角色上还没有,实例化武器,使其成为玩家手的父对象。记住,我们的角色是绑定的,有一个手骨,所以你应该把它放在那里。

    在本书中下载的角色所带的武器是一个特殊场景,其中武器具有 SkinnedMeshRenderer 组件。该组件使用我们将在第十七章,动画现实:使用 Animator、Cinemachine 和 Timeline 创建动画 中学习的 Skinning Animation 系统。在这种情况下,武器的运动将受到我们将在该章中使用的动画的影响,因此现在让我们保持武器现在的位置,即使它看起来有点奇怪。

  3. 创建或获取一个枪口粒子系统。在这种情况下,我的枪口粒子系统被创建为一个短粒子系统,它会爆发粒子然后自动停止。尝试获取具有该行为的粒子系统,因为还有其他一些会循环,而处理这种情况的脚本将不同。

  4. 在编辑器中创建粒子系统的 Prefab 实例,并将其作为武器的子对象放置其中,使其位于武器前方,与枪的炮管对齐。确保粒子系统 Main 模块的 Play On Awake 属性未被勾选;我们不希望在按下 Fire 键之前发射枪口:

图 11.70:与武器关联的枪口

  1. PlayerShooting 中创建一个名为 muzzleEffectParticleSystem 类型的字段。

  2. 将在枪中关联的枪口效果 GameObject 拖到检查器中。现在,我们有了对枪口 ParticleSystem 组件的引用来管理它。

  3. 在检查我们是否在射击的if语句中执行muzzleEffect.Play();以播放粒子系统。它将自动停止,并且足够短,可以在按键之间完成:

图形用户界面、文本、应用程序  自动生成的描述

图 11.71:枪口附加到武器上

可视化脚本版本的附加节点和变量如下所示:

图形用户界面、应用程序  自动生成的描述

图 11.72:枪口播放可视化脚本

最后,我们还需要在射击时通过以下方式在 AI 上播放枪口效果:

  1. 如同PlayerShooting一样,在EnemyFSM中创建一个名为muzzleEffectParticleSystem类型的字段。

  2. Shoot方法中,在方法末尾添加muzzleEffect.Play();行以播放粒子系统:

图 11.73:发射时播放 C#脚本

可视化脚本版本的攻击状态攻击Base的附加节点将如下所示:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 11.74:播放攻击状态脚本的枪口

记得将那些节点添加到攻击状态中,并将muzzleEffect变量添加到 AI 变量组件中。

摘要

在本章中,我们讨论了创建粒子系统的两种不同方法:使用常规粒子系统和 VFX 图。我们使用它们来模拟不同的流体现象,如火焰、瀑布、烟雾和雨。想法是将粒子系统与网格结合以生成场景所需的全部可能道具。此外,正如你可以想象的那样,创建这类效果需要你深入了解。

如果你想要致力于这个(技术艺术家工作的另一个部分),你需要学习如何创建自己的粒子纹理以获得你想要的精确外观和感觉,编写控制系统某些方面的脚本,以及粒子创建的几个其他方面。然而,这超出了本书的范围。

现在我们场景中有了一些雨,我们可以看到天空和场景中的光照并不真正反映雨天,所以让我们在下一章中修复这个问题!

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家和作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会议与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

第十二章:《照亮世界:使用通用渲染管线照亮场景》

照明是一个复杂的话题,有几种可能的方式来处理它,每种方法都有其优缺点。为了获得最佳的质量和性能,你需要确切地知道你的渲染器如何处理照明,这正是我们将在本章中探讨的内容。我们将讨论在 Unity 的通用渲染管线URP)中如何处理照明,以及如何正确配置它以适应场景的氛围,并使用适当的照明效果。

在本章中,我们将探讨以下照明概念:

  • 应用照明

  • 应用阴影

  • 照明优化

在本章结束时,我们将正确使用不同的 Unity 照明系统,如直接光照和光照贴图,来反映多云和雨夜的景象。

应用照明

当讨论在游戏中处理照明的不同方法时,我们有两种主要的方法,称为前向渲染延迟渲染,每种方法都有一些变体。它们以不同的顺序、不同的技术、不同的要求和优缺点来处理照明。前向渲染通常推荐用于性能,而延迟渲染通常推荐用于质量。

此外,在 Unity 中,前向渲染器有三种模式:多次遍历前向,用于内置渲染器(旧的 Unity 渲染器),单次遍历前向,默认用于 URP,以及单次遍历前向+,这是HDRP高清晰度渲染管线)支持的唯一形式,也是 URP 的一个选项。我们有几个模式,因为每个模式都有不同的特性和限制,使它们适用于不同类型的游戏。

选择哪种方法取决于你正在创建的游戏类型以及你需要在该平台上运行游戏。你选择的选项将因你如何将照明应用到场景中而大量改变,因此了解你正在处理哪个系统至关重要。

在下一节中,我们将讨论以下实时照明概念:

  • 讨论照明方法

  • 使用天空盒配置环境照明

  • 在 URP 中配置照明

让我们先比较一下之前提到的照明方法。

讨论照明方法

回顾一下,我们提到了处理照明的三种主要方法:

  • 前向渲染(单次遍历)

  • 前向渲染(多次遍历)

  • 前向+渲染(单次遍历)

  • 延迟渲染

请参考文档了解如何为测试启用这些模式中的每一个。例如,要在 URP 中启用延迟渲染,请使用此链接:docs.unity3d.com/Packages/com.unity.render-pipelines.universal@15.0/manual/rendering/deferred-rendering-path.html#how-to-enable

在我们查看它们之间的差异之前,让我们谈谈它们共有的东西。这三个渲染器开始绘制场景,通过确定哪些物体可以被相机看到——即那些落在相机视锥体(相机所看到的区域)内的物体——并提供一个当选择相机时可以看到的巨大金字塔:

Image result for unity occlusion culling

图 12.1:相机仅渲染其视锥体内的物体,裁剪(或隐藏)那些在外的物体

之后,Unity 将按照从相机最近到最远(透明物体处理方式略有不同,但在此我们先忽略这一点)的顺序对它们进行排序。这样做的原因是因为靠近相机的物体更有可能覆盖大部分相机视野,因此它们会遮挡其他物体(会阻止其他物体被看到),从而避免我们浪费资源去计算被遮挡物体的像素。

最后,Unity 将尝试按照这个顺序渲染物体。这就是光照方法之间开始出现差异的地方,所以让我们开始比较两种前向渲染变体。对于每个物体,单次遍历前向渲染将在一次操作中计算物体的外观,包括所有影响该物体的光源,或者我们称之为绘制调用。

绘制调用是 Unity 请求显卡实际渲染指定物体的确切时刻。Unity 所执行的所有先前工作(例如,设置将要使用的着色器)都是为了这一刻做准备。在多遍历前向渲染器的情况下,通过稍微简化实际逻辑,Unity 将根据影响物体的每个光源渲染物体一次;因此,如果物体被三个光源照亮,Unity 将渲染物体三次,这意味着将发出三个绘制调用,并对 GPU 进行三次调用以执行渲染过程:

图片 B21361_12_02_PE.png

图 12.2:左图,多遍历中受两个光源影响的球体的第一次绘制调用;中图,球体的第二次绘制调用;右图,两次绘制调用的组合

现在你可能正在想,“为什么我要使用 Multi-Pass?Single Pass 性能更好!”是的,你是对的!Single Pass 比 Multi-Pass 性能更好,这意味着我们的游戏将以更高的帧率运行,尽管这里有一个大但。GPU 中的绘制调用可以执行的操作数量是有限的,所以你有一个绘制调用复杂性的限制。计算一个物体及其影响它的所有光源的外观非常复杂,为了使其适应仅一个绘制调用,Single Pass 执行了光照计算的简化版本,这意味着光照质量较低,功能较少。它们还对一次可以处理的光源数量有限制,在撰写本书时,每个物体为八个;如果你愿意,可以配置得更少,但默认值对我们来说已经足够好了。这听起来像是一个小数字,但通常已经足够了。

另一方面,Multi-Pass 可以应用你想要的任意数量的光源,并且可以为每个光源执行不同的逻辑。假设我们的物体有四个影响它的光源,但有两个光源因为它们更近或强度更高,对物体的影响非常显著,而剩下的光源对物体的影响只是足够引起注意。在这种情况下,我们可以用更高的质量渲染前两个光源,而剩下的光源则用简单的计算——没有人会注意到区别。

在这种情况下,Multi-Pass 可以使用像素光照计算前两个光源,而剩下的光源则使用顶点光照。区别在于它们的名称;像素是按对象像素计算光照,而顶点是按对象顶点计算光照,并填充这些顶点之间的像素,从而在顶点之间插值信息。你可以在以下图像中清楚地看到区别:

图 12.3:左图,使用顶点光照渲染的球体;右图,使用像素光照渲染的球体

在 Single Pass 中,在一个绘制调用中计算所有内容迫使你必须使用顶点光照或像素光照;你不能将它们结合起来。

最后,我们还有 Forward+,这是 Single-Pass Forward 的一个变体,但为了渲染更多的光源,需要一些额外的处理。如果你计划使用 Forward 但想要支持超过限制的光源,你应该选择这个选项。

因此,为了总结单次遍历和多次遍历之间的差异,在单次遍历中,由于每个对象只绘制一次,因此性能更好,但你受到可应用光源数量的限制,而在多次遍历中,你需要渲染对象多次,但光源数量没有限制,并且你可以为每个光源指定你想要的精确质量。还有其他一些需要考虑的事情,例如实际绘制调用的成本(一个绘制调用可能比两个简单的调用更昂贵),以及如卡通着色等特殊光照效果,但让我们保持简单。

最后,让我们简要讨论一下延迟渲染。即使我们不会使用它,了解为什么我们不这样做也是很有趣的。在确定哪些对象位于视锥体内并对它们进行排序后,延迟渲染将不应用任何光照来渲染对象,生成所谓的 G-Buffer。G-Buffer 是一组包含有关场景中对象不同信息的图像,例如像素的颜色(无光照)、每个像素的方向(称为 法线)以及像素与相机之间的距离。

你可以在以下图像中看到 G-Buffer 的典型示例:

图 12.4:左图,对象的纯色;中图,每个像素的深度;右图,像素的法线

法线是方向,方向的 xyz 分量编码在颜色的 RGB 分量中。

在渲染场景中的所有对象之后,Unity 将遍历相机中可见的所有光源,从而在 G-Buffer 上应用一层光照,从中获取信息来计算特定光源。处理完所有光源后,你将得到以下结果:

图 12.5:对前一个图像中显示的 G-Buffer 应用了三个光源的组合

如你所见,这种方法中的延迟部分源于将光照计算作为渲染过程的最后阶段的想法。这更好,因为你不会浪费资源去计算可能被遮挡的对象的光照。如果图像的地面首先在 前向 模式下渲染,那么其他对象将要遮挡的像素将被无用地计算。此外,延迟渲染的优点是它只计算光照能够到达的确切像素。例如,如果你使用手电筒,Unity 将只计算手电筒锥体内的像素的光照。这里的缺点是延迟渲染不支持一些相对较旧的显卡,并且你不能使用顶点光照质量来计算光照,因此你需要付出像素光照的代价,这在低端设备上(甚至在简单的图形游戏中)是不推荐的(甚至可能是必要的)。

那么,为什么我们使用 URP 和单次绘制前向(默认值)?因为它在性能、质量和简单性之间提供了最佳平衡。在这个游戏中,我们不会使用太多光照,所以我们不会担心单次绘制的光照数量限制。如果你需要更多光照,可以使用延迟或前向+,但请考虑额外的硬件要求以及没有顶点光照选项的性能成本。

因此,为了总结我们迄今为止所看到的内容,让我们比较一下这些系统:

  • 前向渲染单次绘制):在单个 GPU 绘制调用中渲染对象,每个对象应用有限数量的光照(撰写本文时为 9 个)。允许使用顶点光照或像素光照(后者对 GPU 的负担更大)。如果你的游戏与光照数量限制相匹配,这是首选选项。

  • 前向渲染多遍绘制):使用每像素光照进行一次额外的绘制调用,但可以渲染无限数量的光照。在 URP 上不可用,因此我们无法在本项目中使用它。

  • 前向+渲染单次绘制):绕过单次绘制前向的限制,允许每个对象渲染无限数量的光照;但不支持顶点光照。当需要比常规前向更多光照时推荐使用。它。

  • 延迟渲染:首先渲染所有对象,然后对每个光照接触的像素应用光照。当使用大量光照时推荐使用。测试在最低目标设备上,Forward+或延迟渲染在你的设备硬件、游戏配置和光照数量下哪个表现更好。

如果你想了解更多关于不同的渲染路径以及如何激活它们的信息,请查看此链接:https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@15.0/manual/urp-universal-renderer.html#rendering-path-comparison

既然我们已经对 URP 如何处理光照有了非常基本的了解,让我们开始使用它吧!

使用天空盒配置环境光照

有不同的光源可以影响场景,例如太阳、手电筒和灯泡。这些被称为直接光照——即发射光线的对象。然后,我们有间接光照,它表示直接光照如何在其他对象上反弹,如墙壁。然而,计算所有光源发出的所有光线的所有反弹是非常昂贵的,从性能角度来看,需要支持光线追踪的特殊硬件。问题是,没有间接光照将产生不真实的结果,你可以观察到阳光无法到达的地方完全黑暗,因为没有来自光线击中其他地方的光线反弹。

在下一张图片中,你可以看到这种配置错误场景的示例:

图 12.6:无环境光照的山上投影的阴影

如果您遇到这个问题,通过使用那些反弹的近似值来解决问题的性能方法是有效的。这些就是我们所说的环境光。这代表了一个基础的光照层,通常根据天空的颜色应用一点光,但您可以选择任何您想要的颜色。例如,在晴朗的夜晚,我们可以选择深蓝色来代表月光带来的色调。

如果您在 Unity 2023 中创建一个新的场景,通常这是自动完成的,但在没有自动完成或通过其他方法创建场景的情况下,了解如何通过以下操作手动触发此过程是方便的:

  1. 点击窗口 | 渲染 | 光照。这将打开场景光照设置窗口:

图 12.7:光照设置位置

  1. 点击窗口底部的生成光照按钮。如果您到目前为止还没有保存场景,将弹出一个提示要求您保存,这是必要的:

图 12.8:生成光照按钮

  1. 查看 Unity 窗口的右下角,检查进度计算条,以查看何时完成过程:

计算机截图  描述自动生成,置信度低

图 12.9:光照生成进度条

  1. 现在,您可以看到完全黑暗的区域是如何被天空发出的光照亮的:

图 12.10:带有环境光照的阴影

现在,通过这样做,我们有了更好的光照,但它仍然看起来像晴天。记住,我们想要的是雨天。为了做到这一点,我们需要更改默认的天空,使其多云。您可以通过下载一个skybox来实现这一点。您现在在场景周围看到的当前天空只是一个包含每个面的纹理的大立方体,这些纹理有一个特殊的投影,以防止我们检测到立方体的边缘。

我们可以为立方体的每一面下载六张图片,并将它们应用到我们想要的任何天空,让我们这样做:

  1. 您可以从任何地方下载 skybox 纹理,但在这里,我将选择 Asset Store。通过访问窗口 | Asset Store并进入 Asset Store 网站来打开它。

  2. 在右侧的分类列表中查找分类 | 2D | 纹理与材质 | 天空。记住,如果您看不到分类列表,请尝试将窗口变宽:

手机截图  描述自动生成,置信度低

图 12.11:纹理与材质

  1. 记得在价格选项中勾选免费资源复选框。

    选择任何适合雨天的一天天空盒。考虑到天空盒有不同的格式。我们使用的是六图像格式,所以在下载之前请检查。还有一种格式叫做立方体贴图,本质上与它相同,但我们将继续使用六图像格式,因为它是最简单且易于使用和修改的格式。在我的情况下,我选择了图 12.12中显示的天空盒包。下载并导入它,就像我们在第四章中做的那样,无缝集成:导入和整合资源

    手机截图,自动生成描述,置信度低

    图 12.12:为此本书选择的天空盒集

  2. 通过在项目窗口中使用+图标并选择材质来创建一个新的材质。

  3. 将该材质的Shader选项设置为Skybox/6 面。记住,天空盒只是一个立方体,因此我们可以应用材质来改变其外观。天空盒着色器已准备好应用六个纹理。

  4. 将六个纹理拖动到材质的FrontBackLeftRightUpDown属性。六个下载的纹理将具有描述性的名称,以便你知道哪些纹理放在哪里:

图片

图 12.13:天空盒材质设置

  1. 将材质直接拖动到场景视图中的天空上。确保不要将材质拖动到对象上,因为材质将被应用到它上面。

  2. 重复环境光计算步骤的步骤 14光照设置 | 生成光照)以根据新的天空盒重新计算它。在以下图像中,你可以看到我项目到目前为止的结果:

图片

图 12.14:应用的天空盒

现在我们有一个良好的光照基础层,我们可以开始添加灯光对象。

在 URP 中配置光照

我们可以向场景添加三种主要的动态灯光类型:

  • 方向光:这是一种代表太阳的灯光。该对象以它面对的方向发射光线,无论其位置如何。例如,如果你慢慢旋转这个对象,你可以生成白天/夜晚周期:

包含截图、设计、图形设计、图形的图片,自动生成描述

图 12.15:方向光结果

  • 点光源:这种灯光代表一个灯泡,以全向方式发射光线。与方向光相比,它的不同之处在于其位置很重要,因为它更靠近我们的对象。此外,因为它是一种较弱的灯光,所以光强度会根据距离变化,因此其效果有一个范围——物体离光源越远,接收到的强度越弱:

图片

图 12.16:点光源结果。黄色圆圈代表光的范围

  • 聚光灯:这种灯光代表一个光锥,例如手电筒发出的光。它在行为上类似于点光源,其位置很重要,光线强度会在一定距离内衰减。但在这里,它指向的方向(因此它的旋转)也很重要,因为它将指定光线投射的位置:

包含截图、折纸的图片,自动生成的描述

图 12.17:聚光灯效果。黄色锥体代表光照区域

到目前为止,我们已经有了很好的、下雨的、环境照明,但场景中唯一的直接光源,即方向光,设置不正确;这看起来像是场景中的实际天气,所以让我们改变一下:

  1. 层次结构窗口中选择方向光对象,然后查看检查器窗口。

  2. 点击发射部分的颜色属性以打开颜色选择器。

  3. 选择深灰色以实现太阳光被云层部分遮挡的状态。

  4. 阴影类型设置为无阴影。既然是多云天气,太阳不会投射清晰的阴影,但我们稍后会更多地讨论阴影:

图片

图 12.18:无阴影的柔和方向光

现在场景变暗了,我们可以添加一些灯光来照亮场景,如下所示:

  1. 通过转到游戏对象 | 光 | 聚光灯来创建一个聚光灯。

  2. 选择它。然后,在检查器窗口中,在形状部分将内/输出聚光角度设置为90120,这将增加锥形的角度。

  3. 发射部分将范围设置为50,这意味着光线可以达到 50 米,并在途中衰减。

  4. 发射部分将强度设置为1000

图片

图 12.19:聚光灯设置

  1. 将灯光放置在游戏基地的一个角落,指向中心:

图片

图 12.20:聚光灯位置

  1. 通过选择它并按Ctrl+D(在 Mac 上为Command+D)来复制该灯光。

  2. 将它放置在基地的对面角落:

图片

图 12.21:两个聚光灯效果

你可以继续向场景添加灯光,但要注意不要过度——记住灯光限制。此外,你可以在灯光所在的位置下载一些灯柱来从视觉上证明光源的起源。现在我们已经实现了适当的照明,我们可以谈谈阴影。

应用阴影

也许你认为场景中已经有了阴影,但实际上并没有。物体的较暗区域,即不面向灯光的区域,没有阴影——它们没有被照亮,这与阴影有很大不同。在这种情况下,我们指的是从一个物体投射到另一个物体的阴影——例如,玩家投射到地板上的阴影,或者从山脉投射到其他物体上。

影子可以增强我们场景的质量,但它们在计算上也很耗费资源,因此我们有两种选择:不使用影子(推荐用于低端设备,如手机)或者根据我们的游戏和目标设备在性能和质量之间找到一个平衡点。

图片

图 12.22:区域 1:一个没有光照的区域,因为它没有面向光源。区域 2:一个没有光照的区域,因为它被另一个立方体所遮挡

在本节中,我们将讨论以下关于影子的主题:

  • 理解影子计算

  • 配置高性能的影子

让我们首先讨论 Unity 如何计算影子。

理解影子计算

在游戏开发中,众所周知,影子在性能方面是昂贵的,但为什么?当一个光束在到达目标物体之前击中另一个物体时,物体就会产生影子。在这种情况下,该光不会对该像素应用任何光照。这里的问题与我们处理环境光照模拟中的光的问题相同——计算所有可能的光线和它们的碰撞将非常昂贵。因此,我们再次需要一个近似值,这就是影子贴图发挥作用的地方。

影子贴图是从光的角度渲染的图像,但它不会绘制带有所有颜色和光照计算的完整场景,而是将所有物体以灰度渲染,其中黑色表示像素非常远离光源,而更白则表示像素更接近光源。如果你这么想,每个像素都包含有关光线射线击中位置的信息。通过了解光源的位置和方向,你可以使用影子贴图计算出每个“射线”击中的位置。

在以下图像中,你可以看到我们方向光的影子贴图:

一个红色方块物体,背景为黑色  描述由低置信度自动生成

图 12.23:由场景中的方向光生成的影子贴图

第十八章性能魔法:使用 Profiler 工具优化你的游戏中,我们将看到如何使用帧调试器分析所有绘制调用。这允许你看到影子贴图的生成。

每种光线类型在计算影子贴图时略有不同,尤其是点光源。由于它是全方向的,它需要从所有方向(前、后、左、右、上、下)渲染场景多次,以便收集关于它发出的所有光线的信息。不过,我们在这里不会详细讨论这个问题,因为我们可以整天都在讨论它。

现在,这里需要强调的一个重要问题是,影子贴图是纹理,因此它们具有分辨率。分辨率越高,我们的影子贴图计算的光线就越多。你可能想知道低分辨率影子贴图是什么样子,当它里面只有很少的光线时。看看以下图像以查看一个例子:

图片

图 12.24:使用低分辨率阴影图渲染的硬阴影

这里的问题是,射线越少,产生的阴影像素就越大,导致阴影出现像素化。在这里,我们考虑的第一个配置是:我们的阴影的理想分辨率是多少?你可能会想直接增加它,直到阴影看起来平滑,但当然,这将增加计算所需的时间,因此会显著影响性能,除非你的目标平台能够处理它(移动设备肯定不能)。在这里,我们可以使用软阴影技巧,在阴影上应用模糊效果以隐藏像素化的边缘,如下面的图像所示:

包含截图、设计、艺术的图片,自动生成描述

图 12.25:使用低分辨率阴影图渲染的软阴影

当然,模糊效果不是免费的,但结合低分辨率阴影图,如果你接受其模糊的结果,可以在质量和性能之间产生良好的平衡。

现在,低分辨率的阴影图还有一个问题,这被称为阴影痤疮。这是你在以下图像中可以看到的照明错误:

图片

图 12.26:低分辨率阴影图产生的阴影痤疮

低分辨率的阴影图产生假阳性,因为它计算出的“射线”较少。需要在射线之间着色的像素需要从最近的像素中插值信息。阴影图的分辨率越低,射线之间的间隙就越大,这意味着精度更低,假阳性更多。一个解决方案是增加分辨率,但同样,会有性能问题(总是如此)。

我们有一些巧妙的解决方案,比如使用深度偏差。以下图像就是一个例子:

包含线条、平行、矩形、设计的图片,自动生成描述

图 12.27:两个远“射线”之间的假阳性。高亮区域认为射线在到达之前击中了物体

深度偏差的概念很简单——简单到似乎是一种很大的作弊,实际上确实如此,但游戏开发中充满了这样的技巧!为了防止假阳性,我们将光线稍微推远一点,刚好足够让插值光线达到被照亮的表面:

包含线条、平行、矩形、设计的图片,自动生成描述

图 12.28:具有深度偏差的光线以消除假阳性

当然,正如你可能预料的那样,没有一些注意事项,这个问题是不会轻易解决的。增加深度会在其他区域产生假阴性,如下面的图像所示。看起来立方体像是漂浮的,但实际上它是在接触地面——假阴性产生了它漂浮的错觉:

图片

图 12.29:由于高深度偏差产生的假阴性

当然,我们有一种对抗这种情况的技巧,称为法线偏差;这种方法特别通过将对象网格在对象面向的方向上移动来调整对象,而不是跟随光线的路径。这个方法有点棘手,所以我们不会在这里过多详细说明,但想法是结合一点深度偏差和一点法线偏差可以减少误报,但不会完全消除它们。因此,我们需要学会如何与之共存,并通过巧妙地定位对象来隐藏这些阴影差异:

一个立方体的特写  描述自动生成,置信度低

图 12.30:减少了误报,这是结合深度和法线偏差的结果

影响阴影贴图工作方式的其他几个方面,其中之一是光程。光程越小,阴影覆盖的区域就越小。相同的阴影贴图分辨率可以增加该区域的更多细节,所以尽量减少光程,就像我们在下一节中将要做的那样。

我可以想象你现在的心情;是的,光照很复杂,我们只是刚刚触及表面!但保持你的士气!经过一点尝试和错误地调整设置后,你会更好地理解它。我们将在下一节中这样做。

如果你真的对了解阴影系统内部结构感兴趣,我建议你看看阴影级联的概念,这是一个关于方向光和阴影贴图生成的进阶主题。

配置高性能阴影

因为我们的目标是中端设备,所以我们将尝试在这里实现质量和性能的良好平衡,所以让我们开始只为聚光灯启用阴影。方向光阴影不会那么明显,实际上,雨天不会产生清晰的阴影,所以我们将以此为借口不计算那些阴影。为了做到这一点,请执行以下操作:

  1. 在层次结构中点击两个聚光灯,同时按Ctrl(在 Mac 上为Command),这将确保在检查器窗口中进行的任何更改都将应用于两者:

包含文本、字体、屏幕截图、图形的图片  描述自动生成

图 12.31:选择多个对象

  1. 在检查器窗口中,将阴影类型阴影部分设置为软阴影。在这里我们将使用低分辨率的阴影贴图,软模式可以帮助隐藏像素化的分辨率:

计算机屏幕截图  描述自动生成,置信度低

图 12.32:软阴影设置

  1. 选择方向光并将阴影类型设置为无阴影以防止其产生阴影:

图 12.33:无阴影设置

  1. 创建一个立方体(GameObject | 3D Object | Cube),并将其放置在灯光附近,以便我们有一个可以用于测试目的投射阴影的对象。

现在我们有一个基本的测试场景,让我们调整阴影图分辨率设置,防止阴影痤疮:

  1. 前往编辑 | 项目设置

  2. 在左侧列表中,查找图形并单击它:

图 12.34:图形设置

  1. 在选择此选项后出现的属性中,点击可脚本渲染管线设置下面的框——包含名称的那个。在我的情况下,这是URP-HighFidelity,但如果您有不同版本的 Unity,它可能不同:

图 12.35:当前渲染管线设置

  1. 执行此操作将在项目窗口中突出显示一个资产,因此在选择之前请确保窗口是可见的。选择突出显示的资产:

图 12.36:当前管线突出显示

  1. 此资产包含多个与 URP 如何处理其渲染相关的图形设置,包括光照和阴影。展开光照部分以显示其设置:

图 12.37:管线光照设置

  1. 附加光源子部分下的阴影图分辨率设置表示所有非方向光源的阴影图分辨率(因为它是主光源)。如果它尚未设置为该值,请将其设置为1024

  2. 阴影部分下,您可以查看深度法线偏差设置,但它们将影响所有光源。即使现在我们的方向光没有阴影,我们也只想影响额外的光源偏差值,因为它们的阴影图分辨率与主光源(方向光)不同。因此,选择聚光灯,并将偏差设置为自定义,将深度法线偏差设置为0.25,以便在我们移除阴影痤疮之前尽可能减少它们:

计算机屏幕截图,自动生成,置信度低

图 12.38:偏差设置

  1. 这并不完全与阴影相关,但在通用渲染管线设置资产中,您可以更改每个对象光照限制以增加或减少可以影响对象的光源数量(不超过八个)。目前,默认设置就很好。

  2. 如果您遵循了之前提供的阴影级联提示,您可以稍微调整级联值以启用方向光阴影并注意其效果。请记住,这些阴影设置仅适用于方向光。

  3. 方向光中没有阴影,但在任何其他情况下,请考虑在阴影部分中减少最大距离值,这将影响方向光阴影的范围。

  4. 在层次结构中选择两个聚光灯,并将它们的范围设置为 50 米。看看在这次更改前后阴影的质量是如何提高的。

记住,这些值只适用于我的情况,所以试着调整一下这些值,看看它们是如何改变结果的——如果你设计的场景与我的不同,你可能会找到一个更好的设置。此外,记住没有阴影始终是一个选项,所以如果你的游戏每秒帧数较低(也称为 FPS)且没有其他性能问题,也请考虑这一点。

你可能认为在光照性能方面我们只能做到这一步了,但幸运的是,情况并非如此!我们还有另一种资源可以利用来进一步改进它,这就是所谓的静态光照。

优化光照

我们之前提到,不计算光照对性能有好处,但如果不计算灯光,但仍然有灯光呢?是的,这听起来太好了,以至于不真实,但实际上是可能的(当然,也很棘手)。我们可以使用一种称为静态光照或烘焙的技术,它允许我们一次性计算光照并使用缓存的计算结果。

在本节中,我们将介绍与静态光照相关的以下概念:

  • 理解静态光照

  • 烘焙光照贴图

  • 将静态光照应用于动态对象

理解静态光照

这个想法很简单:只需进行一次光照计算,保存结果,然后使用这些结果而不是每次都进行光照计算。

你可能想知道为什么这不是默认的技术。这是因为它有一些限制,最大的限制是动态对象。预计算阴影意味着一旦计算完成,它们就不能改变,但如果一个投射阴影的对象被移动,阴影仍然会存在。所以,这里要考虑的主要问题是你不能使用这种技术来处理移动的对象。相反,你需要为静态对象混合静态烘焙光照,对于动态(移动)对象则使用实时光照。此外,考虑到这种技术只适用于静态对象,它也只适用于静态灯光。再次强调,如果灯光移动,预计算的数据就变得无效了。

你需要考虑的另一个限制是,预计算的数据可能会对内存产生巨大影响。这些数据占用 RAM 空间,可能达到数百 MB,因此你需要考虑你的目标平台是否有足够的空间。当然,你可以降低预计算光照的质量以减小数据的大小,但你需要考虑这种质量损失是否会对你的游戏的外观和感觉产生太大的影响。就像所有关于优化的选项一样,你需要平衡两个因素:性能和质量。

在我们的过程中,我们有几种预先计算的数据,但最重要的一个是所谓的光照贴图。光照贴图是一种包含场景中所有对象的所有阴影和光照的纹理,因此当 Unity 应用预先计算或烘焙的数据时,它会查看这个纹理以确定静态对象的哪些部分被照亮,哪些没有被照亮。

你可以在以下图像中看到一个光照贴图的例子:

Image result for lightmap unity

图 12.39:左,没有光照的场景;中,包含该场景预先计算数据的光照贴图;右,光照贴图被应用到场景中

拥有光照贴图有其自身的优点。烘焙过程在游戏发布给用户之前在 Unity 中执行,因此你可以花大量时间计算在运行时无法执行的事情,例如提高精度、光照反弹、角落的光照遮挡以及发射物体的光照。然而,这也可能是一个问题。记住,动态对象仍然需要依赖于实时光照,而且与静态光照相比,这种光照看起来会很不同,所以我们需要调整很多,以便用户不会注意到差异。

现在我们对静态光照有了基本的了解,让我们深入了解如何使用它。

烘焙光照贴图

要使用光照贴图,我们需要对 3D 模型做一些准备工作。记住,网格有UV,它包含有关纹理的哪一部分需要应用到模型每个部分的信息。有时,为了节省纹理内存,你可以将同一块纹理应用到不同的部分。例如,在汽车的纹理中,你不会有多达四个轮子;你只有一个,并且你可以将同一块纹理应用到所有的轮子上。这里的问题是静态光照以相同的方式使用纹理,但在这里,它将应用光照贴图来照亮对象。

在轮子场景中,问题可能是如果一个轮子接收到阴影,所有的轮子都会有,因为所有的轮子共享相同的纹理空间。通常的解决方案是在模型中有一组没有共享纹理空间的第二个 UV 集,专门用于光照贴图。

有时,下载的模型已经为光照贴图做好了准备,有时则没有,但幸运的是,Unity 在这些情况下为我们提供了支持。为了确保模型能够正确计算光照贴图,让我们通过以下步骤让 Unity 自动生成光照贴图 UV

  1. 项目窗口中选择网格资产(FBX)。

  2. 模型选项卡中,找到底部的生成光照贴图 UV复选框并勾选它。

  3. 点击底部的应用按钮:

A screenshot of a computer  Description automatically generated with medium confidence

图 12.40:生成光照贴图 UV 设置

  1. 对每个模型重复此过程。技术上,你只能在烘焙光照贴图后出现伪影和奇怪结果的模型中这样做,但为了以防万一,我们现在就在所有模型中这样做。

在准备模型进行光照贴图后,下一步是告诉 Unity 哪些对象不会移动。为此,请执行以下操作:

  1. 选择不会移动的对象。

  2. 在检查器窗口右上角勾选静态复选框:

图 12.41:静态复选框

  1. 对每个静态对象重复此操作(对于灯光来说这不是必要的;我们稍后会处理这些)。

  2. 你还可以选择多个对象的容器,勾选静态复选框,并在提示中点击是,所有子对象按钮,将复选框应用到所有子对象。

考虑到你可能不希望每个对象,即使它是静态的,都被光照贴图,因为光照贴图的对象越多,你需要的纹理大小就越大。例如,地形可能太大,会消耗大部分光照贴图的大小。通常,这是必要的,但在这个案例中,聚光灯几乎接触不到地形。在这里,我们有两种选择:将地形保留为动态的,或者更好的是,直接告诉聚光灯不要影响地形,因为其中一个只受到环境光和方向光(不会投射阴影)的影响。

记住,我们之所以能这样做,是因为我们的场景类型;然而,在其他情况下,你可能需要使用其他设置。你可以通过以下方式从实时和静态光照计算中排除一个对象:

  1. 选择要排除的对象。

  2. 在检查器窗口中,点击下拉菜单,然后点击添加层…

图 12.42:创建层按钮

  1. 在这里,你可以创建一个层,这是一个用于识别哪些对象不会受到光照影响的对象组。在列表中,寻找一个空白区域,并输入任何名称来命名这类对象。在我的案例中,我只排除地形,所以我将其命名为地形

图 12.43:层列表

  1. 再次选择地形,转到下拉菜单,并选择之前步骤中创建的层。这样,你可以指定这个对象属于那一组对象:

计算机屏幕截图  描述由中等置信度自动生成

图 12.44:更改 GameObject 的层

  1. 选择所有聚光灯,在检查器窗口的渲染部分查找剔除遮罩,点击它,取消选中之前创建的层。这样,你可以指定这些灯光不会影响那一组对象:

图 12.45:光照剔除遮罩

  1. 现在,你可以看到那些选定的灯光并没有照亮或投射到地形上。

现在,是时候设置灯光了,因为静态复选框对它们不起作用。对于它们,我们有以下三种模式:

  • 实时:实时模式下的灯光将影响所有对象,无论是静态的还是动态的,使用实时光照,这意味着没有预先计算。这对于不是静态的灯光很有用,例如玩家的手电筒、因风而移动的灯等。

  • 烘焙:实时模式的对立面,这种灯光只会影响带有光照贴图的静态对象。这意味着如果玩家(动态)在街道(静态)上的烘焙灯光下移动,街道看起来会被照亮,但玩家仍然保持黑暗,不会在街道上投下任何阴影。这种想法是用于不会影响任何动态对象的灯光,或者对它们几乎不可见的灯光,这样我们就可以通过不计算它们来提高性能。

  • 混合:如果你不确定使用哪种模式,这是首选模式。这种灯光将为静态对象计算光照贴图,但也会影响动态对象,将其实时光照与烘焙光照相结合(就像实时灯光也做的那样)。

在我们的案例中,我们的方向光将只影响地形,而且因为我们没有阴影,在 URP 中应用光照相对便宜,所以我们可以将方向光保留为实时,这样它就不会占用任何光照贴图纹理区域。

我们的聚光灯正在影响基础,但实际上,它们只是在应用光照——我们没有阴影,因为我们的基础是空的。在这种情况下,最好是根本不计算光照贴图。然而,为了学习目的,我将添加一些作为障碍物到基础上的对象,以投射一些阴影并证明使用光照贴图的必要性,如图所示:

图片

图 12.46:添加对象以投射光线

在这里,你可以看到我们的关卡原始设计在游戏开发过程中是如何不断变化的,这是你无法避免的——游戏的大部分内容会随着时间的推移而改变。现在,我们准备设置灯光模式并执行烘焙过程,如下所示:

  1. 在层次结构中选择方向光

  2. 在检查器窗口的通用部分将模式属性设置为实时(如果它还没有处于该模式)。

  3. 选择两个聚光灯。

  4. 将它们的渲染模式设置为混合

图片

图 12.47:聚光灯的混合光照设置;方向光模式将是实时

  1. 打开光照设置窗口(窗口 | 渲染 | 光照)。

  2. 我们想要更改烘焙过程的某些设置。为了启用这些控制,点击新建按钮。这将创建一个具有光照贴图设置的资产,可以应用于多个场景,以防我们想要多次使用相同的设置:

图片

图 12.48:创建光照设置

  1. 降低光照贴图的质量,以便加快处理过程。再次强调,可以通过使用光照贴图分辨率直接采样间接采样环境采样等设置来轻松降低照明质量,所有这些设置都位于光照贴图设置类别下。在我的情况下,我已经将这些设置应用如下所示。请注意,即使降低这些设置也需要时间;由于模块化关卡设计,场景中对象太多:

图 12.49:场景照明设置

  1. 点击生成照明,这是我们之前用来生成环境照明的相同按钮。

  2. 等待处理完成。您可以通过检查 Unity 编辑器右下角的进度条来完成此操作。请注意,在大型场景中,这个过程可能需要数小时,所以请耐心等待:

图 12.50:烘焙进度条

  1. 在处理完成后,您可以检查照明设置窗口的底部部分,在那里您可以查看需要生成多少光照贴图。我们有一个最大光照贴图分辨率,因此我们可能需要几个来覆盖整个场景。此外,它还会告诉我们它们的大小,以便我们可以考虑它们在内存方面的影响。最后,您可以在烘焙光照贴图部分查看它们:

图 12.51:生成的光照贴图

  1. 现在,根据结果,您可以移动对象、修改光照强度,或进行任何必要的校正,以使场景看起来如您所愿,并在需要时重新计算照明。在我的情况下,这些设置给了我足够好的结果,您可以在以下图像中看到:

图 12.52:光照贴图结果

我们还有很多小的设置要讨论,但我将让您通过试错或阅读 Unity 关于光照贴图的文档来发现这些内容,文档可在docs.unity3d.com/Manual/Lightmappers.html找到。阅读 Unity 手册是获取知识的好方法,我建议您开始使用它——无论经验如何丰富,任何优秀的开发者都应该阅读手册。

将静态照明应用于静态对象

当您在场景中将对象标记为静态时,您可能已经发现场景中的所有对象都不会移动,因此您可能为每个人检查了静态复选框。这没问题,但您应该始终将一个动态对象放入场景中,以确保一切正常——没有游戏是完全静态的场景。尝试添加一个胶囊并将其移动到周围,以模拟以下图像中的玩家。如果您注意观察,您会注意到一些奇怪的事情——由光照贴图过程生成的阴影没有被应用到我们的动态对象上:

图 12.53:光照贴图预计算阴影下的动态对象

你可能认为混合光照模式应该影响动态和静态对象,这正是它所做的事情。问题在于,与静态对象相关的一切,包括它们投射的阴影,都被预先计算到那些光照贴图中,而由于我们的胶囊是动态的,所以在预计算过程中并没有包含在内。因此,在这种情况下,由于投射阴影的对象是静态的,它的阴影不会影响任何动态对象。

在这里,我们有几种解决方案。第一种是将静态和实时混合算法更改为使相机附近的物体使用实时光照并防止这个问题(至少在玩家注意力的焦点附近),这将大大影响性能。另一种选择是使用光探针。当我们烘焙信息时,我们只在光照贴图上做了这件事,这意味着我们只在表面上有关光照的信息,而不是在空旷的空间中。由于我们的玩家正在穿越这些表面之间的空旷空间,我们不知道这些空间中的光照会是什么样子,比如走廊的中间。光探针是一组位于这些空旷空间中的点,Unity 也会在这些点上预先计算信息,因此当一些动态对象通过光探针时,它会从它们那里采样信息。在下面的图像中,你可以看到一些已经应用于我们场景的光探针。你会注意到那些处于阴影中的将会变暗,而那些暴露在光中的将会具有更大的强度。

这种效果将应用于我们的动态对象:

图 12.54:代表光探针的球体

如果你现在将你的对象在场景中移动,它将响应阴影,如下面的两张图像所示,你可以看到动态对象在烘焙阴影外被照亮,而在阴影内变暗:

图 12.55:动态对象接收来自光探针的烘焙光照

为了创建光探针,请执行以下操作:

  1. 通过访问GameObject | Light | Light Probe Group创建一个光探针组。

  2. 幸运的是,我们有一些关于如何放置它们的指导方针。建议将它们放置在光照变化的地方,比如在阴影的内外。然而,这很复杂。最简单且推荐的方法是将光探针网格覆盖在可玩区域上。为此,你可以简单地多次复制和粘贴光探针组来覆盖整个基础:

图 12.56:光探针网格

  1. 另一种方法是选择一个组并点击编辑光探针按钮进入光探针编辑模式。这是在场景视图中选择光探针(看起来像三个相连的点)后,出现在顶部左侧按钮栏中的最后一个按钮:

图 12.57:光探针组编辑按钮

  1. 点击全选按钮,然后复制所选以复制所有之前存在的探头。

  2. 使用“平移工具”将它们移到上一个旁边,在这个过程中扩展网格。考虑到探头越近,你需要覆盖的地形就越多,这将生成更多数据。然而,从性能角度来看,光探头数据相对便宜,所以你可以有很多,如图 12.55所示。

  3. 重复步骤 4步骤 5,直到覆盖整个区域。

  4. 使用光照设置中的生成光照按钮重新生成光照。

这样,你就在光探头上预先计算了灯光,影响我们的动态对象,将两个世界结合起来以获得一致的光照。

灯光是一个复杂的话题;甚至有些人专门从事确保你的游戏中的灯光正确且高效的工作。如果你想了解更多,有很多资源,如下面的链接:https://blog.unity.com/engine-platform/shedding-light-on-universal-render-pipeline-for-unity-2021-lts

摘要

在本章中,我们讨论了几个光照话题,例如 Unity 如何计算灯光和阴影,如何处理不同的光源,如直接和间接照明,如何配置阴影,如何烘焙光照以优化性能,以及如何结合动态和静态光照,以确保灯光不会与它们影响的世界脱节。这是一个很长的章节,但灯光值得这样。它是一个可以极大地改善场景外观和感觉的复杂主题,同时也可以显著降低性能。它需要大量的练习,在这里,我们试图总结所有你需要开始实验的重要知识。对这个主题要有耐心;很容易得到错误的结果,但你可能只需勾选一个复选框就能解决问题。

现在我们已经尽可能改进了场景设置,在下一章中,我们将使用 Unity 后处理堆栈应用最终层图形效果,这将应用全屏图像效果——那些能给我们带来如今所有游戏都有的电影般外观和感觉的效果。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提问、为其他读者提供解决方案、通过“问我任何问题”的环节与作者聊天,以及更多。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第十三章:逼真沉浸:使用后处理实现全屏效果

到目前为止,我们已经创建了不同的对象,如网格、粒子、灯光,以改变场景的视觉效果。但如果我们真的想要获得那种光滑、现代的游戏外观,是时候尝试一些后处理魔法了。我们可以调整对象这里的设置来提高场景质量,但当你与现代游戏场景比较时,你总会觉得缺少了什么,而缺少的就是后处理效果,即在整个场景处理完成后应用的效果,以增强其视觉效果。在本章中,你将学习如何将效果应用于最终渲染帧,这将改变我们在前几章中创建的整体场景的外观。

在本章中,我们将检查以下图像效果概念:

  • 使用后处理

  • 使用高级效果

让我们先看看我们如何将后处理应用于我们的场景。

使用后处理

后处理是 Unity 的一个功能,允许我们将一系列效果(几个效果)堆叠在一起,以改变图像的最终外观。每一个都会影响最终帧,改变用户看到的最终图像。在图 13.1中,你可以看到应用图像效果前后的场景。你会注意到一个巨大的差异,但那个场景中的对象没有任何变化,包括灯光、粒子网格。

应用的效果是基于每个像素级别的。看看这里两个场景:

图 13.1:没有图像效果的场景(左)和添加了效果的相同场景(右)

在本节中,我们将讨论以下 URP 后处理概念:

  • 设置配置文件

  • 使用基本效果

通过掌握这些 URP 后处理概念,你将能够将你的场景从普通转变为非凡。让我们开始准备我们的场景以应用效果,为此我们需要设置一个配置文件。

设置配置文件

要开始应用效果,我们需要创建一个配置文件,这是一个包含我们想要应用的所有效果和设置的资产。这是一个独立的资产,因为我们可以在不同的场景和场景的不同部分共享相同的后处理配置文件,就像我们处理材质一样。当我们提到场景的部分时,我们指的是体积,即区域、区域或游戏中的区域,这些区域应用了某些效果。我们可以定义一个全局区域,无论玩家的位置如何都应用效果,或者我们可以应用不同的效果——例如,当我们户外或室内时。

在这个例子中,我们将使用一个全局体积,我们将使用它来应用一个配置文件和我们的第一个效果,方法如下:

  1. 创建一个新的空 GameObject(GameObject | 创建空)命名为PP Volume(后处理体积)。

  2. 向其添加体积组件,并确保模式设置为全局

  3. 点击配置文件设置右侧的按钮,这将生成一个与点击按钮时选择的 GameObject 同名的新的Profile资产(PP 体积 配置文件)。将此资产移动到其自己的文件夹中,这有助于资产组织。最终的设置如下面的图所示:

图片

图 13.2:体积组件

  1. 为了测试体积是否工作,让我们添加一个效果。为此,点击添加覆盖按钮并选择后期处理 | 色差选项。

  2. 色差效果的强度复选框中勾选,并将强度设置为0.25,如下面的图所示:

包含文本、截图、字体、数字的图片,描述自动生成

图 13.3:色差效果

  1. 现在,观察色差效果如何微妙地调整图像的角落,为场景添加动态边缘。请记住在场景面板中查看这一点;我们将在下一步将效果应用到游戏视图中。这如下面的图所示:图片

    图 13.4:场景中应用了色差效果

    如果你没有看到效果,请确保切换效果按钮处于开启状态,如下面的图所示。

    图片

    图 13.5:场景视图效果切换

  2. 现在,如果你点击播放并从主相机的视角看游戏,你会看到效果没有被应用,这是因为我们需要在我们的主相机的渲染部分的后期处理复选框中勾选,如下面的图所示:

图片

图 13.6:启用后期处理

在我们的全球体积设置完成后,我们刚刚解锁了在整个场景中一致应用效果的能力,无论我们的玩家走到哪里。

现在我们已经为场景准备好了后期处理,我们可以开始尝试不同的效果。让我们在下一节从最简单的效果开始。

使用基本效果

现在我们已经在场景中设置了后期处理,我们只需要开始添加效果并设置它们,直到我们得到期望的外观和感觉。为了做到这一点,让我们探索系统包含的几个简单效果。

让我们从色差开始,这是我们刚刚使用的,就像大多数图像效果一样,它试图复制一个特定的现实生活效果。所有游戏引擎渲染系统都使用一个简单的数学近似来描述人类视觉的真正工作方式,因此,我们没有一些在人类眼睛或相机镜头中发生的效果。真实的相机镜头通过弯曲光线来指向相机传感器,但这种弯曲在某些镜头中并不完美(有时是故意为之),因此,你可以看到扭曲,如下面的截图所示:

图片

图 13.7:无色差图像(顶部)和有色差图像(底部)

这个效果将是我们添加的几个效果之一,以在我们的游戏中营造出电影感,模拟现实生活中的相机使用。当然,这个效果并不是在所有类型的游戏中都看起来很好;也许简单的卡通风格不会从这个效果中受益,但你永远不知道:艺术是主观的,所以这是一个试错的问题。

此外,我们在前面的例子中稍微夸张了强度,以便使效果更明显,但我建议在这种情况下使用 0.25 的强度。通常建议对效果的强度要温和;虽然强烈的视觉效果很有吸引力,但因为你将添加很多效果,所以过一段时间后,图像会因为过多的扭曲而变得膨胀。所以,尝试添加几个微妙的效果而不是几个强烈的效果。但,再次强调,这取决于你寻找的目标风格;这里没有绝对真理(但常识仍然适用)。

最后,在讨论其他效果之前,如果你习惯于使用其他类型的后期处理效果框架,你会注意到这个版本的色差设置较少,这是因为 URP 版本追求性能,所以它会尽可能简单。

我们接下来要讨论的效果是晕影。这是另一种相机镜头不完美的情况,图像强度在镜头边缘丢失。这不仅可以用来自动模拟旧相机,还可以将用户的注意力引向相机的中心——例如,在电影场景中。

此外,如果你正在开发虚拟现实VR)应用程序,这可以用来通过减少玩家的周边视野来减少运动病。在下面的屏幕截图中,你可以看到一个旧相机的晕影示例:

图片结果

图 13.8:使用旧相机拍摄的照片,边缘有晕影

为了尝试一下,让我们通过以下步骤在我们的场景中应用一些晕影:

  1. 选择 PP Volume 实体对象。

  2. 通过点击 添加覆盖 按钮添加 后期处理 | 晕影 效果。

  3. 打开 强度 复选框并将其设置为 0.3,以增强效果。

  4. 打开 平滑度 复选框并将其设置为 0.5;这将增加效果的扩散。你可以在下面的图中看到结果:

图片

图 13.9:晕影效果

如果你想,你可以通过打开 颜色 复选框并设置另一个值来更改颜色;在我们的例子中,黑色可以很好地加强雨天环境。在这里,我邀请你检查其他属性,如 中心圆角。你只需调整数值就能创造出很好的效果。

我们将要看到的另一个效果是 运动模糊,同样,它模拟了相机的工作方式。真实的相机有一个曝光时间:它需要捕获图像中光子的时间。当一个物体移动得足够快时,在短暂的曝光时间内,同一个物体会放置在不同的位置,因此它会显得模糊。在下面的截图中,你可以看到效果已经应用于我们的场景。

在这个图像的情况下,我们快速上下移动相机,结果如下:

图 13.10

图 13.10:将运动模糊应用于我们的场景

需要考虑的一点是,这种模糊效果只会应用于相机移动,而不会应用于物体的移动(静止相机,移动物体),因为目前这个 URP 还不支持运动矢量。

为了使用这个效果,请按照以下步骤操作:

  1. 使用 添加覆盖 按钮添加 后处理 | 运动模糊 覆盖。

  2. 打开 强度 复选框并将其设置为 0.25

  3. 在查看 游戏 视图(而不是 场景 视图)的同时旋转相机。你可以点击并拖动相机的 变换X 属性(不是值——X 标签),如图下截图所示:

图 13.11

图 13.11:改变旋转

如你所见,这种效果在 场景 视图中也看不到,以及其他效果,所以在得出效果不起作用的结论之前请考虑这一点。Unity 这样做是因为在场景中工作时有这种效果会很烦人。

最后,我们将简要讨论两个简单效果,胶片颗粒白平衡

  1. 第一步相当简单:添加它,将强度设置为 1,你将得到老电影中著名的颗粒效果。你可以通过设置不同的尺寸来调整 类型,使其更加微妙或强烈。

  2. 第二个,白平衡允许你改变色温,根据你的配置使颜色变得更暖或更冷。在我们的案例中,我们正在处理一个寒冷、昏暗的场景,因此你可以添加它并将温度设置为 -20,以略微调整外观并改善这种场景的外观和感觉。

当我学习如何制作自己的后处理效果时,我记得曾经尝试过一种梦幻般的过渡扭曲效果。为此,我使用了正弦数学函数来计算基于像素的垂直位置应用的水平扭曲量。这样做之后,我发现我的帧率大幅下降,那一刻我意识到后处理可能会很昂贵。公平地说,那是一台旧电脑,但仍然,这个观点是成立的。注意添加的后处理效果的数目,并了解每个效果,以了解如何正确配置它。你可以使用像 PIX 这样的工具来分析 GPU 的成本,它分析应用程序的 GPU 性能:devblogs.microsoft.com/pix/download/

现在我们已经看到了一些简单效果,让我们来看看受一些高级渲染功能影响的剩余效果。

使用高级效果

现在我们正在通过一些高级效果提升我们的水平。它们比我们之前看到的要高一个档次,也稍微复杂一些,但不用担心——我们会引导您了解它们。在本节中,我们将看到以下高级效果概念:

  • 高动态范围(HDR)和深度图

  • 应用高级效果

让我们先讨论一下这些效果正常工作所需的一些要求。

高动态范围(HDR)和深度图

记得我们上一章提到的深度图吗?一些效果不仅需要渲染的图像,还需要像这样的额外数据才能真正发挥作用。有些效果不仅与渲染图像一起工作,还需要额外的数据。我们首先讨论深度图,这是一个我们在前几章讨论过的概念。

深度图是从摄像机的视角渲染的图像,但它不是生成场景的最终图像,而是渲染场景对象的深度,以灰色阴影的形式呈现对象。在深度图中,规则很简单:阴影越深,像素离摄像机越远,越浅则越近。

在下面的屏幕截图中,您可以看到深度图的一个示例:

Image result for depth buffer

图 13.12:注意灰色阴影的变化如何描绘出与摄像机的距离

我们将看到一些效果,例如景深,它将根据摄像机的距离模糊图像的某些部分,但可以在自定义效果(不在基础 URP 包中)的多个用途上使用。

在这里要讨论的另一个概念将改变颜色的处理方式,从而影响一些效果的工作方式,那就是高动态范围HDR)。在旧硬件中,颜色通道(红色、绿色和蓝色)被编码在 0 到 1 的范围内,0 代表没有强度,1 代表全强度(每个通道),因此所有光照和颜色计算都在这个范围内进行。这看起来似乎没问题,但并不反映光的实际工作方式。您可以在被阳光照亮的纸张上看到全白(所有通道都设置为 1),您可以直接看灯泡看到全白,但即使光和纸张颜色相同,后者首先会在一段时间后刺激眼睛,其次,由于过多的光线会有一些溢光。这里的问题是最大值(1)不足以表示最强烈的颜色,所以如果您有一个高强度的光源和另一个强度更高的光源,两者都会生成相同的颜色(每个通道都是 1),因为计算不能超过 1。为了克服这些限制并更真实地捕捉光线,创建了HDR 渲染

HDR 是一种让颜色超出 0 到 1 范围的方法,因此基于颜色强度工作的光照和效果在此模式下具有更好的准确性。Unity 将在 HDR 模式下进行计算,但最终图像仍然会使用之前的光栅空间(0 到 1,或低动态范围(LDR))进行工作,所以不要将 Unity 的 HDR 渲染与显示器的 HDR 混淆。

关于 HDR 的更多信息,请查看此链接:docs.unity3d.com/Manual/HDR.html

将 HDR 计算转换回 LDR 时,Unity(以及电视)使用了一个称为色调映射的概念。您可以在以下屏幕截图中看到一个 LDR 渲染的场景示例以及色调映射在 HDR 场景中的应用:

图 13.13:LDR 渲染的场景(左)和经过色调映射校正过度的 HDR 场景(右)

色调映射是一种将超出 0-1 光强度范围的颜色转换回此范围以在 LDR 屏幕上渲染的方法。它基本上使用曲线来确定每个颜色通道应该如何映射回。

想象一下:你正从昏暗的房间走出,进入刺眼的午后阳光。那一刻的耀眼效果和逐渐调整就是 HDR 在起作用。这就像从黑暗的房间走到明亮的阳光下;一开始,一切都很亮,但渐渐地,你的眼睛适应了——这就是色调映射的作用。这里的想法是,无论你在室内还是室外,计算都是一样的;室内的白墙将有接近 1 的强度颜色,而室外的同一堵白墙将有更高的值(由于阳光)。区别在于,当你在室外时,色调映射会将高于 1 的颜色调整回 1,如果整个场景较暗,它可能会根据你的设置增加墙面的光照。这个功能被称为自动曝光

即使 HDR 默认启用,我们也可以通过以下步骤来验证它:

  1. 转到编辑 | 项目设置

  2. 在左侧面板中单击图形部分。

  3. 单击可脚本渲染管线设置属性下的引用资产。

  4. 项目面板中单击突出显示的资产。在单击图形设置中的属性之前,请确保此面板是可见的。或者,您可以在图形设置中双击资产引用以选择它。

  5. 质量部分下,确保已勾选HDR,如图所示:

图 13.14:启用 HDR

  1. 确保在主相机游戏对象的相机组件的HDR 渲染属性设置为使用渲染管线设置,以确保上一步骤中的更改得到尊重。

当然,HDR 可切换的事实意味着存在一些你不想使用 HDR 的场景。正如你所猜想的,并非所有硬件都支持 HDR,使用它会导致性能开销,所以请考虑这一点。幸运的是,大多数效果都支持 HDR 和 LDR 色彩范围,所以如果你启用了 HDR 但用户设备不支持,你不会收到任何错误,只是效果根据效果而有所不同,例如图像更亮或更暗,或者夸张的效果,就像我们在下一节应用高级效果中将要看到的那样。

现在我们确信已经启用了 HDR,让我们探索一些使用 HDR 和深度映射的高级效果。

应用高级效果

让我们看看使用之前描述的技术的一些效果,从常用的Bloom开始。此效果模拟了在相机镜头或甚至人眼周围一个强光照射物体上的溢光。在图 13.15中,你可以看到我们场景的默认版本和夸张的 Bloom 版本之间的区别。

你可以观察到效果只应用于场景中最亮的部分。看看这两个效果:

图 13.15:默认场景(左)和具有高亮度 Bloom 的相同场景(右)

这个效果实际上非常常见且简单,但我认为它是高级的,因为结果会受到 HDR 的极大影响。此效果依赖于计算每个像素颜色的强度来检测可以应用该效果的区域。在 LDR 中,我们可以有一个不是过亮的白色物体,但由于此色彩范围的限制,Bloom 可能会在其上产生溢光。在 HDR 中,由于其增加的色彩范围,我们可以检测到物体是否为白色,或者物体可能是浅蓝色但只是过亮,从而产生它是白色的错觉(例如靠近高亮度灯的物体)。在图 13.16中,你可以看到带有 HDR 和无 HDR 的场景之间的区别。你会注意到 LDR 版本将在不一定过亮的区域产生溢光。差异可能非常微妙,但请注意细节以注意差异。并且记住,我在这里夸张了效果。看看这两个场景:

图 13.16:LDR 场景中的 Bloom(左)和 HDR 场景中的 Bloom(右)。注意,Bloom 设置被更改以尽可能接近它们

目前,让我们继续使用场景的 HDR 版本。为了启用 Bloom,请执行以下操作:

  1. 按照惯例,将Bloom覆盖添加到配置文件中。

  2. 通过勾选复选框启用强度选项,并将值设置为0.2。这控制了将应用多少溢光。

  3. 启用阈值并将其设置为0.7。这个值表示颜色需要达到的最小强度,才能被认为是溢光。在我们的案例中,我们的场景有些昏暗,因此我们需要在光晕效果设置中降低这个值,以便包含更多的像素。像往常一样,这些值需要根据你的具体场景进行调整。

你会注意到差异非常微妙,但再次提醒,你会拥有几个效果,所以所有这些细微的差异都会累积起来。你可以在以下屏幕截图中看到这两种效果:

图 13.17:光晕效果

像往常一样,建议你调整其他值。我推荐你测试的一些有趣的设置是污渍纹理污渍强度值,这将模拟溢光区域的脏镜头。

在我们对深度图的理解基础上,让我们探索另一个流行的效果,景深。这个效果依赖于我们之前讨论过的深度图。它对肉眼来说并不明显,但当你聚焦于你视野中的某个对象时,周围的物体因为不在焦点上而变得模糊。我们可以利用这一点来在游戏的关键时刻吸引玩家的注意力。想象这个效果就像相机的自动对焦一样——它采样深度图来决定什么应该是清晰的,什么应该是模糊的,就像在摄影中对物体进行对焦一样。

为了使用它,请按照以下步骤操作:

  1. 将摄像机靠近一根柱子,尝试聚焦于一个特定对象,如下面的截图所示:

图 13.18:摄像机定位

  1. 添加景深覆盖。

  2. 启用并设置模式设置为高斯:在性能方面最经济的选项。

  3. 在我的案例中,我将开始设置为10,将结束设置为20,这将使效果在目标物体后方一定距离开始。结束设置将控制模糊强度的增加,在20米处达到最大。请记住根据你的情况调整这些值。

  4. 如果你想稍微夸张一下效果,将最大半径设置为1.5。结果如下面的截图所示:

图 13.19:夸张效果

在这里需要考虑的是,我们的游戏将采用俯视视角,并且与第一人称摄像机不同,你可以看到远处的物体,在这里,我们将有足够近的物体,以至于不会注意到效果,因此我们可以将此效果仅限于我们场景中的过场动画。

现在,大多数剩余的效果都是改变场景实际颜色的不同方式。想法是,实际颜色有时并不能给你你寻求的确切外观和感觉。你可能需要将暗区调得更暗以增强恐怖氛围的感觉,或者你可能想相反:增加暗区以表示一个开阔的场景。也可能你想要将高光稍微着色以获得霓虹效果,如果你正在创建一个未来派游戏,或者你可能想要暂时使用棕褐色效果来做一个闪回。我们有很多种方法来做这件事,在这种情况下,我将使用一个简单但强大的效果,称为阴影 中间调 高光

此效果将对阴影、中间调和高光应用不同的颜色校正,这意味着我们可以分别修改暗部、亮部和中等区域。让我们通过以下步骤尝试一下:

  1. 添加阴影 中间调 高光覆盖。

  2. 让我们开始进行一些测试。检查三个阴影中间调高光复选框。

  3. 阴影中间调滑块全部移到最左边,将高光滑块移到最右边。这将减少阴影和中间调的强度,并增加高光的强度。我们这样做是为了让你可以看到高光将根据强度改变的区域。你可以用其他滑块做同样的操作来检查其他两个区域。你可以在下面的屏幕截图中看到结果:

图片

图 13.20:隔离高光

  1. 此外,测试将彩色圆圈中心的白色圆圈移动以对这些区域应用一点着色。通过将滑块稍微向左移动来减少高光的强度,使着色更明显。你可以在下面的屏幕截图中看到结果:

图片

图 13.21:着色高光

  1. 通过这样做,你可以探索这些控件是如何工作的,但当然,这些极端值对于某些边缘情况是有用的。在我们的场景中,以下屏幕截图中的设置对我来说效果最好。和往常一样,使用更微妙的值更好,这样不会过度扭曲原始结果,如下所示:

图片

图 13.22:细微变化

你可以在下面的屏幕截图中看到前后效果:

图片

图 13.23:前后效果

如果你寻找的比 HDR 更简单的效果,分割色调提供了一种更简单但有效的方法来玩阴影和高光,或者颜色曲线,它让你可以更高级地控制场景中每个颜色通道的映射,但想法是相同的:改变最终场景的实际颜色,以给你的场景应用特定的颜色氛围。如果你记得电影系列《黑客帝国》,当角色在矩阵中时,一切都有细微的绿色着色,而在外面时,着色是蓝色。

记住,使用 HDR 和不使用 HDR 对这些效果的结果很重要,因此最好是尽早而不是稍后决定是否使用 HDR,排除某些目标平台(这可能对你的目标受众并不重要),或者不使用 HDR(使用 LDR)并减少对场景光照级别的控制。

此外,考虑到你可能需要调整一些对象的设置,例如光照强度和材质属性,因为有时我们会使用后期处理来修复由设置错误的对象引起的图形错误,这是不可接受的。例如,增加场景中的环境光照将极大地改变效果输出,如果我们发现场景太暗,我们可以利用这一点来提高整体亮度,而不是使用效果。

我们现在已经介绍了一系列图像效果。记住,理念不是使用每一个,而是使用你认为有助于你场景的效果;它们在性能方面不是免费的(尽管不是那么资源密集),所以要明智地使用。此外,你可以检查已经创建的配置文件,并将它们应用到你的游戏中,看看微小的变化可以带来巨大的差异。

摘要

在本章中,我们讨论了在场景中应用的基本和高级全屏效果,使场景在相机镜头效果方面看起来更真实,在色彩扭曲方面更时尚。我们还讨论了 HDR 和深度图的内部结构以及它们在使用这些效果时的重要性,这些效果可以以最小的努力立即提高你游戏的图形质量。

现在我们已经涵盖了 Unity 系统中常见的图形效果,接下来让我们看看如何在下一章中通过使用声音来提高场景的沉浸感。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提问,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第十四章:和谐的声音景观:集成音频和音乐

我们现在已经达到了足够的图形质量,但我们缺少游戏美学的关键部分:声音。声音通常被推迟到游戏开发的最后一步,它是那些即使存在,你也不会注意到其存在,但如果没有它,你会觉得缺少了什么的东西之一。它可以帮助你加强你在游戏中想要营造的氛围,并且必须与图形设置相匹配。为此,我们将使用 Unity 的能力来导入音频资产,在 2D 和 3D 定位中播放它们,并使用其混音器进行音频混音。

在本章中,我们将探讨以下声音概念:

  • 导入音频

  • 集成和混音音频

我们将在游戏中应用这些概念,导入音频以在不同的场景中播放——例如当玩家射击时——以及音乐。在后面的编程章节中,我们将播放声音,但现在,让我们专注于如何将它们导入到我们的项目中。

导入音频

就像图形资源一样,正确设置音频资源的导入设置非常重要,因为如果设置不当,导入可能会消耗大量资源。

在本节中,我们将探讨以下音频导入概念:

  • 音频类型

  • 配置导入设置

让我们先讨论我们可以使用的不同类型的音频。

音频类型

在视频游戏中存在不同类型的音频,以下是这些类型:

  • 音乐:音乐用于根据情况增强玩家的体验。

  • 音效(SFX):作为对玩家或 NPC 动作的反应而产生的声音,例如点击按钮、行走、开门、射击枪支等等。

  • 环境音效:如果一个游戏只对事件做出声音反应,会显得很空洞。如果你正在重建城市中间的公寓,即使玩家只是在房间中间无所事事,也应该听到很多声音,其中大部分声音的来源将位于房间外,例如飞机在头顶上飞行,两个街区外的建筑工地,街道上的汽车等等。创建玩家看不到的对象是一种资源浪费。相反,我们可以在场景的各个地方放置单个声音来重现所需的氛围,但这会消耗大量资源,需要大量的 CPU 和 RAM 才能达到令人信服的效果。考虑到这些声音通常占据用户注意力的第二平面,我们可以将它们全部组合成一个循环轨道,并播放一个音频文件,这就是环境音效。如果你想创建一个咖啡馆场景,你只需去真正的咖啡馆,录制几分钟的音频,并将其用作环境音效。

对于几乎所有的游戏,我们至少需要一个音乐轨道、一个环境轨道和几个音效来开始音频制作。像往常一样,我们有不同的音频资产来源,但我们将使用资产商店。它有三个音频类别来搜索我们需要的资产:

手机截图  描述自动生成,置信度低

图 14.1:资产商店中的音频类别

在我的情况下,我也使用了搜索栏来进一步筛选类别,搜索weather以找到雨声效果。有时,你可能找不到单独的精确音频;在这种情况下,你将需要深入到中,所以在这里要有耐心。在我的情况下,我选择了你可以在图 14.2中看到的三个包,但只导入了其中的一些声音,因为所有这些声音在项目中的大小都会很大。对于环境音,我选择了这个包中的一个名为Ambience_Rain_Moderate_01_LOOP的雨声文件,但如果你下载了另一个包,我们寻找的雨声文件名称可能不同。然后,我选择了音乐 – 悲伤的希望作为音乐,对于音效,我选择了一个枪声效果包,用于我们未来玩家的英雄角色。当然,你可以选择其他包以更好地满足你游戏的需求:

音乐专辑截图  描述自动生成,置信度中等

图 14.2:我们游戏的包

现在我们已经拥有了必要的音频包,让我们来讨论如何导入它们。

配置导入设置

我们有几种可以调整的导入设置,但问题是我们需要考虑音频的使用情况来正确设置它们,所以让我们看看每种情况下的理想设置。为了查看导入设置,就像往常一样,你可以选择资产并在检查器面板中查看,如图下所示:

计算机截图  描述自动生成,置信度中等

图 14.3:音频导入设置

让我们从最重要的一个开始讨论,即Force To Mono。有些音频可能包含立体声通道,这意味着我们在左耳和右耳分别播放不同的声音。这意味着一段音频实际上可以包含两个不同的音频轨道。立体声音在音乐中用于不同的效果和乐器空间化,所以我们希望在那些场景中拥有它,但还有其他场景中单声道音频更受欢迎。

考虑 3D 音效,例如枪声或某些行走步伐。在这些情况下,我们需要声音在源的方向上被听到——如果枪声发生在我左边,我需要听到它从左边传来。在这些情况下,我们可以通过在音频导入设置中勾选强制转换为单声道复选框,将立体声音频转换为单声道音频。这将使 Unity 将两个通道合并为一个,通常将音频大小减少到原来的一半(有时更多,有时更少,取决于各种因素)。

你可以在音频资产检查器的底部验证该设置和其他设置的影响,在那里你可以看到导入的音频大小:

包含文本、截图、数字、字体的图片,自动生成描述

图 14.4:顶部:未使用“强制转换为单声道”导入的音频。底部:使用“强制转换为单声道”的相同音频

接下来要讨论的下一个设置,并且是一个重要的设置,是加载类型。为了播放某些音频,Unity 需要从磁盘读取音频,解压缩它,然后播放。加载类型改变了这三个处理过程的方式。我们这里有以下三个选项:

  • 加载时解压缩:最占用内存的选项。此模式会在场景加载时,将音频以未压缩的形式加载到内存中。这意味着音频将占用大量的 RAM 空间,因为我们已经加载了未压缩的版本。使用此模式的优势在于播放音频更简单,因为我们有原始音频数据在 RAM 中准备好播放。

  • 流式传输:与“加载时解压缩”正好相反。此模式永远不会将音频加载到 RAM 中。相反,当音频播放时,Unity 会从磁盘读取音频资产的一部分,解压缩它,播放它,然后重复这个过程,为流式传输中播放的每一部分音频运行一次。这意味着此模式将非常占用 CPU 资源,但几乎不会消耗任何 RAM 字节。

  • 内存中压缩:中间方案。此模式会在场景加载时从磁盘加载音频,但将其保持压缩状态存储在内存中。当 Unity 需要播放音频时,它只需从 RAM 中取出一部分,解压缩它,然后播放。记住,从 RAM 中读取音频资产的部分比从磁盘读取要快得多。

也许,如果你是一位经验丰富的开发者,你可以轻松地确定哪种模式更适合哪种类型的音频,但如果你是第一次接触视频游戏,这可能会听起来很困惑。所以,让我们讨论不同情况下的最佳模式:

  • 频繁的短音频:这可能是一声枪响或脚步声,这些声音持续不到一秒,但可以多次发生并同时播放。在这种情况下,我们可以使用加载时解压缩。未压缩的短音频与压缩版本的大小差异不会很大。此外,由于这是性能最好的 CPU 选项,多个实例对性能的影响不会很大。

  • 不频繁的大音频:这包括音乐、环境声音和对话。这类音频通常只播放一个实例,而且通常很大。这些情况更适合流式模式,因为在对这些音频进行压缩或解压缩时,可能会对低端设备(如移动设备)的 RAM 消耗产生巨大影响(在 PC 上,我们有时可以使用内存中压缩)。CPU 可以处理在流式模式下播放两个或三个音频实例,但尽量不要超过这个数量。

  • 频繁的中等音频:这包括多人游戏中的预制语音聊天对话、角色表情、长爆炸声或任何超过 500 KB(这不是一个严格的规定——这个数字很大程度上取决于目标设备)的音频。在 RAM 中对这类音频进行解压缩可能会对性能产生明显影响,但由于这种音频使用频率较高,我们可以将其压缩存储在内存中。它们相对较小的尺寸意味着它们通常不会对我们游戏的整体大小产生巨大影响,我们也将避免浪费 CPU 资源从磁盘读取。

还有其他需要考虑的情况,但可以根据前面的例子进行推断。记住,前面的分析是在考虑标准游戏的要求的基础上进行的,但根据你的游戏和目标设备可能会有很大差异。也许你正在制作一个不会消耗太多 RAM 但 CPU 资源相当密集的游戏,在这种情况下,你可以将所有内容都设置为加载时解压缩。考虑你游戏的各个方面并相应地平衡资源是很重要的。

最后,还需要考虑压缩格式,这会改变 Unity 在发布游戏中对音频的编码方式。不同的压缩格式会以降低原始音频的保真度或增加解压缩时间为代价,提供不同的压缩比率,所有这些都会根据音频模式和长度有很大差异。我们有三种压缩格式:

  • PCM:未压缩的格式将提供最高的音频质量,没有噪声伪影,但会导致更大的资产文件大小。

  • ADPCM:以这种方式压缩音频可以减小文件大小,并产生快速、无压缩的过程,但可能会引入在特定类型音频中可察觉的噪声伪影。

  • Vorbis:一种高质量的压缩格式,几乎不会产生失真,但解压缩所需时间更长,因此播放 Vorbis 音频会比其他格式稍微复杂一些。它还提供了一个质量滑块,用于选择压缩的精确程度。

你应该使用哪一个呢?同样,这也取决于你音频的特征。短且平滑的音频可以使用PCM,而长且嘈杂的音频可以使用ADPCM;这种格式引入的失真会被音频本身所隐藏。也许对于压缩失真明显的长且平滑音频,使用 Vorbis 会有所帮助。有时候,这仅仅是一个试错的过程。

可能默认使用 Vorbis,当性能降低时,尝试切换到ADPCM。如果那样导致故障,就切换到PCM。当然,这里的问题是确保音频处理确实是性能问题的原因——也许将所有音频切换到ADPCM并检查是否有所改变是一个检测的好方法,但更好的方法是使用 Profiler,这是一种性能测量工具,我们将在本书的后面看到。

我们还有其他设置,比如采样率设置,同样,通过一点试错,你可以找到最佳的设置。

我已经设置了从资产商店下载的音频,如图 14.5 和 14.6 所示。第一个图显示了我是如何设置音乐和环境音频文件(大文件)的:

计算机的截图  描述自动生成,中等置信度

图 14.5:音乐和环境设置

音乐文件体积较大,应配置为立体声(强制单声道未勾选)并设置为使用流式传输作为加载类型,因为一次只会播放一个实例。对于压缩格式,选择ADPCM,因为在这种情况下使用 Vorbis 并没有显著减小文件大小。

第二个截图显示了我是如何设置音效文件(小文件)的:

计算机的截图  描述自动生成,中等置信度

图 14.6:音效设置

我们下载的声音将是 3D 的,所以应该勾选强制单声道。它们也将是短声音,所以加载类型中的加载时解压缩效果更好。最后,选择Vorbis作为压缩格式可以将ADPCM的大小减少一半以上,这就是我们选择它的原因。

曾经,我被一家需要第二天交付项目的团队雇佣,但他们经常遇到某些音频剪辑在 iPad 1 上无法播放的问题(是的,这发生在很久以前)。他们有很长的、很重的对话,配置为使用加载类型加载时解压缩模式下。我只是切换到流式传输模式,因为那些对话是一次性播放的,解决了问题,并设法提前一天回家。在另一个场合,类似的修复将游戏的主音乐音频剪辑的加载时间从 12 秒减少到 5 秒,在慢速移动设备上。永远不要低估配置不当的音频的成本。

现在我们已经正确配置了音频片段,我们可以在场景中使用它们。

集成和混音音频

我们可以直接将我们的音频片段拖入场景中开始使用,但我们可以进一步挖掘,探索将它们配置到每个可能场景的最佳方式。

在本节中,我们将检查以下音频集成概念:

  • 使用 2D 和 3D 音频源

  • 使用音频混音器

让我们开始探索音频源,这些是负责音频播放的对象。

使用 2D 和 3D 音频源

音频源是可以附加到 GameObject 上的组件。它们负责根据音频剪辑(我们之前下载的音频资产)在我们的游戏中发出声音。

区分音频剪辑音频源很重要;我们可以有一个单一的爆炸音频剪辑,但可以有多个音频源播放它,模拟多个爆炸。可以将音频源视为 CD 播放器,可以播放音频剪辑(在这个类比中是我们的 CD),只是我们可以在同一时间有多个 CD 播放器或音频源播放同一张 CD(例如,同时播放两个爆炸声音)。

创建音频源的最简单方法是从音频剪辑(一个音频资产)中选择并拖动它到层次结构窗口。尽量避免将音频拖动到现有对象中;相反,在对象之间拖动它,这样 Unity 将创建一个新的带有音频源的对象,而不是将其添加到现有对象中(有时,您可能希望现有对象具有音频源,但现在让我们保持简单):

计算机屏幕截图  描述由中等置信度自动生成

图 14.7:将音频剪辑拖动到对象之间的层次结构窗口

下面的屏幕截图显示了通过将音乐资产拖动到场景中生成的音频源。您可以看到音频剪辑字段有一个对拖动音频的引用:

计算机屏幕截图  描述自动生成

图 14.8:配置为播放我们的音乐资产的音频源

如您所见,音频源有多个设置,以下列表中我们将回顾一些常见的设置:

  • 唤醒时播放:确定音频是否在游戏开始时自动播放。我们可以取消选中该选项,并通过脚本播放音频,例如当玩家射击或跳跃时(更多内容请参阅第三部分提升书籍的视觉效果、效果和音频)。

  • 循环:这将使音频在播放结束后自动重复。请记住,始终在音乐和环境音频剪辑上检查此设置。由于这些曲目很长,我们可能在测试中永远无法到达它们的结尾,因此很容易忘记这一点。

  • 音量:控制音频强度。

  • 音调:控制音频速度。这对于模拟慢动作或发动机转速增加等效果很有用。

  • 空间混合:控制我们的音频是 2D 还是 3D。在 2D 模式下,音频将在所有距离上以相同的音量播放,而 3D 将使音频音量随着从相机(或任何持有 AudioListener 组件的 GameObject)的距离增加而减小。

在我们的音乐曲目中,我已经按照以下截图所示进行配置。您可以将环境雨声拖动到场景中,并使用相同的设置,因为我们希望所有场景中都有相同的背景效果。然而,在复杂的场景中,您可以在场景的各个部分分散不同的 3D 环境声音,以根据当前环境改变声音:

计算机截图  描述由中等置信度自动生成

图 14.9:音乐和环境设置。这将循环,设置为唤醒时播放,并且是 2D

现在,您可以将射击效果拖动并配置,如图图 14.10所示。如您所见,在这种情况下,音频不会循环,因为我们希望射击效果每次只播放一次子弹。请记住,对于我们的游戏,子弹将是一个 Prefab,每次我们按下射击键时都会生成,因此每个子弹都将有自己的AudioSource,当子弹被创建时将播放。

此外,子弹的空间混合设置为3D,这意味着效果将根据AudioSource相对于相机位置的不同而通过不同的扬声器传递:

音乐播放器截图  描述由低置信度自动生成

图 14.10:音效设置。这将不会循环,并且是 3D 声音

在 3D 声音的情况下,需要考虑的是音量衰减设置,该设置位于3D 声音设置部分。此设置控制音量如何随着与相机的距离增加而衰减。默认情况下,您可以看到此设置设置为对数衰减,这是现实生活中声音的工作方式,但有时您可能不希望现实生活中的声音衰减,因为现实生活中即使声音来源非常远,声音通常也能听到。

一种选择是切换到线性衰减并使用最大距离设置配置确切的距离:

计算机屏幕截图,描述自动生成,置信度中等

图 14.11:使用线性衰减的最大距离为 10 米的 3D 声音

考虑到我们刚刚讨论了 3D 声音,值得提一下AudioListener组件,这是一个默认在MainCamera中创建的组件,99%的情况下,这个组件会被放置在MainCamera中。它作为识别在游戏中哪个对象代表玩家耳朵的一种方式,通过它可以计算音频方向性。由于它代表用户的眼睛,所以将摄像头作为放置它的逻辑位置,如果玩家的眼睛和耳朵在不同的地方,将会很令人困惑。在AudioListener组件中没有可配置的属性,但重要的是要提到,为了让音频工作,我们需要一个,而且不能超过一个;我们只有一对耳朵:

计算机屏幕截图,描述自动生成,置信度中等

图 14.12:MainCamera 中的 AudioListener 组件

现在我们能够配置单个音频片段,让我们看看如何使用音频混音器应用效果到音频实例的组。

使用音频混音器

我们将在整个游戏中播放多个音频实例:角色的脚步声、射击、篝火、爆炸、雨等等。根据上下文精确控制哪些声音应该更响或更轻,以及应用效果以增强某些情况,例如由于附近的爆炸而受到惊吓,这被称为音频混音——以统一和受控的方式将多个声音混合在一起的过程。

在 Unity 中,我们可以创建一个音频混音器,这是一个我们可以用来定义声音组的资产。对任何组的所有更改都会通过提高或降低音量,或许,或者通过应用效果来影响组内的所有声音。你可以让 SFX 和音乐组分别控制声音。例如,你可以在暂停菜单中降低 SFX 音量,但不降低音乐音量。此外,组以层次结构组织,其中组也可以包含其他组,因此对组的更改也会应用到其子组。实际上,你创建的每个组都将始终是主组的子组,即控制游戏中每个声音(使用该混音器)的组。

让我们创建一个包含 SFX 和音乐组的混音器:

  1. 项目窗口中,使用+按钮,选择音频混音器选项。根据需要命名资产;在我的情况下,我选择了Main Mixer

  2. 双击创建的资产以打开音频混音器窗口:

计算机屏幕截图,描述自动生成,置信度中等

图 14.13:音频混音器窗口

  1. 点击Groups标签右侧的+按钮,创建主节点的子组。命名为SFX

图 14.14:创建组

  1. 点击组,然后再次点击+按钮以创建另一个名为音乐的主节点子组。记住在点击+按钮之前选择组,因为如果选择了另一个组,新组将成为该组的子组。无论如何,您可以通过在AudioMixer窗口的面板中拖动组来重新排列组子父关系:

计算机屏幕截图,自动生成中等置信度的描述

图 14.15:主、SFX 和音乐组

  1. 层次结构窗口中,选择我们场景中的音乐GameObject,并在检查器窗口中查找AudioSource组件。

  2. 点击输出属性右侧的圆圈以打开AudioMixerGroup选择器窗口并选择音乐组。这将使受影响的AudioSource受到指定混音器组设置的约束:

计算机屏幕截图,自动生成中等置信度的描述

图 14.16:使 AudioSource 属于 Audio Mixer 组

  1. 如果你现在玩游戏,你可以看到音频混音器窗口中的音量表开始移动,表明音乐正在通过音乐组。你也会看到组音量表在移动,表明通过音乐组传递的声音也在通过组(音乐组的父组)之前传递到你的电脑的声卡:

包含文本、屏幕截图、图表的图片,自动生成的描述

图 14.17:组音量级别

  1. 对环境声和拍摄声音重复步骤 5步骤 6,使它们属于SFX组。

现在我们已经将声音分成了组,我们可以开始调整组的设置。但在做之前,我们需要考虑到我们不会一直想要相同的设置,就像之前提到的暂停菜单案例中,SFX 音量应该更低。为了处理这些场景,我们可以创建快照,这些是我们的混音器的预设,可以在游戏运行期间通过脚本激活。我们将在本书的第三部分提升视觉效果、效果和音频中处理脚本步骤,但我们可以为游戏设置创建一个正常快照,为暂停菜单设置创建一个暂停快照。

如果您检查快照列表,您将看到已经创建了一个快照——这可以是我们的正常快照。因此,让我们通过以下步骤创建一个暂停快照:

  1. 点击“快照”标签右侧的+按钮,并将快照命名为“暂停”。记住停止游戏以编辑混音器或点击“在播放模式中编辑”选项,以允许 Unity 在播放时更改混音器。如果你选择后者,请记住,停止游戏时更改将保持不变,这与 GameObject 的更改不同。实际上,如果在播放模式期间更改其他资产,这些更改也将保持不变——只有 GameObject 的更改会被撤销。

包含文本、字体、数字、行的图片,描述自动生成

图 14.18:快照创建

  1. 选择暂停快照并降低SFX组的音量滑块:

计算机截图,描述自动生成,置信度中等

图 14.19:降低暂停快照的音量

  1. 播放游戏并听一下声音是否仍然处于正常音量。这是因为原始快照是默认的——您可以通过检查其右侧的星号来看到这一点。您可以通过右键单击任何快照并使用设置为起始快照选项将其设置为默认快照。

  2. 点击在播放模式中编辑以在运行时启用音频混音器的修改。

  3. 点击暂停快照以启用它并听一下射击环境声音量是如何降低的。

如您所见,混音器的主要用途之一是控制组音量,尤其是在您看到某个组的音量强度超过 0 标记时,这表明该组声音太大。无论如何,混音器还有其他用途,例如应用效果。如果您玩过任何战争游戏,您会注意到每当附近发生爆炸时,您会暂时听到不同的声音,好像声音位于另一个房间。这可以通过一个名为低通的效果来实现,它阻止高频声音,这正是我们耳朵在这些场景中发生的情况:爆炸产生的高音量声音的压力会刺激我们的耳朵,使它们对高频的敏感性降低一段时间。

我们可以向任何通道添加效果,并根据当前快照进行配置,就像我们对音量所做的那样,方法如下:

  1. 点击组底部的添加按钮,并选择低通简单

计算机截图,描述自动生成,置信度低

图 14.20:通道的效果列表

  1. 选择正常快照(称为“快照”)以修改它。

  2. 选择组并查看检查器面板,在那里您将看到组及其效果的设置。

  3. 低通简单设置的截止频率属性设置为最高值(22000),这将禁用该效果。

  4. 暂停快照重复步骤 3步骤 4;我们不希望在该快照中应用此效果。

  5. 创建一个新的快照,命名为炸弹昏迷,并选择它进行编辑。

  6. Cutoff freq设置为1000

音乐混音器的截图,描述由低置信度自动生成

图 14.21:设置低通简单效果的截止频率

  1. 玩游戏并切换快照以检查差异。

如您所见,低通使音频听起来像在源和听众之间有一堵墙。除了低通滤波器之外,您还可以应用其他几个过滤器,例如回声,以创建几乎梦幻般的效果,或者使用发送接收降低的组合来根据另一个组的强度降低一个组的音量(例如,当对话发生时,您可能希望降低 SFX 音量)。我邀请您尝试这些和其他效果,并通过阅读以下文档来检查结果以确定潜在用途:docs.unity3d.com/Manual/class-AudioEffectMixer.html

现在我们已经集成了音频,让我们看看我们如何可以脚本化我们的音频。

脚本化音频反馈

与 VFX 一样,音频也需要对游戏中的事件做出反应,以提供更好的沉浸感。让我们开始添加声音到敌人死亡时产生的爆炸效果,这本身可能不需要脚本,但它是最初产生爆炸的脚本的产物:

  1. 从互联网或资产商店下载爆炸音效。

  2. 选择敌人死亡时产生的爆炸预制体,并向其添加一个AudioSource

  3. 将下载的爆炸音频剪辑设置为Audio SourceAudioClip属性。

  4. 确保在Audio SourcePlay On Awake被勾选,Loop未被勾选。这将使声音在爆炸开始时播放,并防止声音在完成后重复播放:

计算机的截图,描述由中等置信度自动生成

图 14.22:为我们的爆炸效果添加声音

如您所见,我们不需要使用任何脚本,因为我们已经在 Audio Source 上激活了在唤醒时播放。由于声音被添加到预制体中,它将在预制体实例化的那一刻自动播放。现在,让我们通过以下方式集成射击声音:

  1. 下载射击声音,并通过 Audio Source 将其添加到玩家的武器枪口效果(而不是武器)中,这次不勾选Play On Awake复选框。

  2. PlayerShooting脚本中,创建一个名为shootSoundAudioSource类型的字段。这样我们的脚本就可以访问组件,并可以控制何时播放声音以及何时不播放。

  3. 层次结构中选择玩家,将武器枪口效果 GameObject 拖到检查器中的射击声音属性,以将脚本与武器枪口效果的 AudioSource 变量连接起来。

  4. 在检查我们是否可以射击的 if 语句中,添加 shootSound.Play(); 行以在射击时执行声音:

包含文本、电子设备、屏幕截图的图片,自动生成描述

图 14.23:射击时添加声音

视觉脚本附加节点看起来将是这样:

包含文本、电子设备、屏幕截图的图片,自动生成描述

图 14.24:在视觉脚本中射击时添加声音

正如我们在枪口效果中所做的那样,我们添加了一个名为 shootSound 的 GameObject 变量来引用包含音频源的武器 GameObject,然后我们调用 shootSound 变量的 Play 方法。

我挑战你尝试在 C# 和视觉脚本版本的脚本中为敌方 AI 添加射击声音。以第十一章引人入胜的视觉效果:利用粒子系统和 VFX 图形中我们处理枪口效果的做法为指南,无论如何,你都可以查看本书的 Git 仓库(可在前言中找到链接)以获取解决方案。

另一种处理方法将与我们在爆炸处理中使用的方法相同;只需将射击声音添加到子弹上,但如果子弹与墙壁碰撞,声音很快就会切断。或者,如果我们未来想要自动武器的声音,它需要实现为一个循环播放的声音,当我们按下相关键时开始播放,当我们释放键时停止播放。这样,我们就可以防止在射击过多子弹时声音实例重叠过多。在选择编写反馈脚本的方法时,要考虑到这些类型的场景。

提升音频沉浸感的另一种方式是通过音频空间化。这是一个高级话题,但你可以在这里了解更多信息:docs.unity3d.com/Manual/AudioSpatializerSDK.html

这在 VR 和 AR 应用程序中特别有用,可以完全沉浸用户在虚拟世界中。

摘要

在本章中,我们讨论了如何导入和集成声音,考虑到它们对内存使用的影响,并考虑了如何应用效果以生成不同的场景。声音是实现所需游戏体验的重要组成部分,因此请花适当的时间来确保其正确性。

现在我们已经涵盖了游戏几乎所有重要的美学方面,让我们创建另一种形式的视觉沟通,即用户界面或 UI。我们将在下一章创建必要的 UI 来显示玩家的当前得分、子弹、生命值以及更多信息。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。扫描二维码或访问链接加入社区:

packt.link/unitydev

第四部分

设计用户界面、动画和高级概念

通过生成可分发可执行文件和性能分析来完善你的项目。同时,学习基础知识以使用 AR Foundation 和 DOTS 开始你的下一个项目。

本节包含以下章节:

  • 第十五章界面卓越:设计用户友好的 UI

  • 第十六章下一代 UI:使用 UI 工具包创建动态界面

  • 第十七章动画现实:使用 Animator、Cinemachine 和 Timeline 创建动画

  • 第十八章性能魔法:使用性能分析工具优化你的游戏

  • 第十九章从原型到可执行:生成和调试你的游戏

  • 第二十章AR/VR

  • 第二十一章庞大世界:DOTS 简介

第十五章:界面精彩:设计用户友好的 UI

屏幕上显示的以及通过电脑扬声器传输的一切都是一种交流形式。在前几章中,我们使用 3D 模型让用户知道他们身处山中一个基地,并通过适当的声音和音乐强化了这个想法。但对我们这款游戏来说,我们需要传达其他信息,例如玩家剩余的生命值和当前得分,有时,使用游戏内的图形来表达这些事情是困难的(有一些成功的案例能够做到这一点,例如死亡空间,但让我们保持简单)。

为了传输这些信息,我们需要在我们的场景上添加另一层图形,这通常被称为用户界面UI)。它将包含不同的视觉元素,如文本框、条形图和按钮,以便用户根据诸如生命值低时逃往安全地点等因素做出明智的决定。

在本章中,我们将探讨以下主题:

  • 理解 Canvas 和 RectTransform

  • Canvas 对象类型

  • 创建响应式 UI

目前,这些术语可能对你来说毫无意义,但为了简化,可以将 Canvas 和 RectTransform 想象成你的 UI 戏剧中的相应舞台和演员。它们至关重要,我们将深入探讨它们的工作方式和协同作用。

到本章结束时,你将能够使用 Unity UI 系统创建能够通知用户游戏状态的界面,并允许他们通过按按钮采取行动。

我们即将揭示 Canvas 和 RectTransform 如何协同工作,使你的游戏界面生动起来。这有点像编排舞蹈——每个元素都有其位置和动作。

理解 Canvas 和 RectTransform

我们将只关注游戏内的 UI,使用 Unity GUI 系统(或 uGUI)向玩家传达不同的信息。在撰写本文时,一个新的 GUI 系统名为 UI Toolkit 已经发布。尽管我们将介绍 UI Toolkit,但请记住,uGUI 在不久的将来不会消失。掌握两者对于你在 UI 设计方法上的灵活性至关重要。我们将在下一章中探讨 UI Toolkit。

如果你打算使用 Unity UI,首先你需要理解其两个主要概念——CanvasRectTransformCanvas是包含并渲染我们 UI 的主对象,而RectTransform是负责在屏幕上定位和调整每个 UI 元素的特性。

在本节中,我们将:

  • 使用 Canvas 创建 UI

  • 使用 RectTransform 定位元素

让我们从使用 Canvas 组件来创建我们的 UI 开始。

使用 Canvas 创建 UI

让我们将理论付诸实践,通过创建基于 Canvas 的 UI。我们将从简单的事情开始,以推动进展。

在 Unity UI 中,你看到的每个图像、文本和 UI 元素都是一个具有一组适当组件的 GameObject,但为了使它们能够工作,它们必须是具有 Canvas 组件的主 GameObject 的子对象。这个组件负责触发 UI 生成并在每个子对象上绘制迭代。我们可以配置这个组件以指定该过程的确切工作方式并适应不同的可能需求。

首先,你可以简单地使用GameObject | UI | Canvas选项创建一个画布。完成之后,你会在场景中看到一个矩形,它代表用户的屏幕,因此你可以将元素放入其中并预览它们相对于用户监视器的位置。

你可能在这里有两个疑问。首先,“为什么矩形在场景的中间?我想让它始终在屏幕上!”不必担心,因为情况确实如此。当你编辑 UI 时,你会将其视为关卡的一部分,作为其中的一个对象,但当你玩游戏时,它将始终投影在屏幕上,覆盖在所有对象之上。此外,你可能想知道为什么矩形这么大,这是因为在使用默认的Canvas Render Mode,即称为Screen Space - Overlay时,屏幕映射中的一个像素对应场景中的一米。有其他模式,但讨论它们超出了本章的范围。

再次强调,不必担心这一点;当你从Game视图中查看游戏时,你会看到所有 UI 元素及其适当的大小和位置在用户的屏幕上。考虑到Scene视图将遵循Game视图的尺寸,建议在Scene视图中编辑之前先设置Game视图的大小。你可以通过点击Game面板顶部的Free Aspect下拉菜单并选择所需的分辨率或纵横比来完成此操作,16:9 Aspect是最常用的选项:

图片

图 15.1:默认图像 UI 元素——一个白色框

在将元素添加到我们的 UI 之前,值得注意的是,当你创建 UI 时,与 Canvas 一起创建了一个名为EventSystem的第二个对象。这个对象对于渲染 UI 不是必需的,但如果你想让 UI 可交互,即包括点击按钮、在字段中输入文本或使用摇杆导航 UI 等操作,则是必需的。EventSystem组件负责采样用户输入,例如使用键盘、鼠标或摇杆,并将这些数据发送到 UI 以做出相应反应。我们可以更改与 UI 交互的确切按钮,但默认设置目前是可接受的,所以只需知道,如果你想与 UI 交互,你需要这个对象。如果由于某种原因你删除了这个对象,你可以在GameObject | UI | Event System中重新创建它。

你正在建立一个坚实的基础。保持这种势头,因为你现在学习的原则在你进步的过程中将至关重要。

使用 RectTransform 定位元素

在 Unity UI 中,你看到的每个图像、文本和 UI 元素都是一个 GameObject,根据其用途具有一组适当的组件,但你将看到它们大多数都有一个共同的组件——RectTransform。UI 的每一部分本质上都是一个填充文本或图像的矩形,并且具有不同的行为,因此理解RectTransform组件的工作原理以及如何编辑它非常重要。

为了实验这个组件,让我们按照以下步骤创建并编辑 UI 中一个简单的白色框元素的定位:

  1. 转到GameObject | UI | Image。之后,你将看到在Canvas元素内创建了一个新的 GameObject。Unity 将负责将任何新的 UI 元素设置为 Canvas 的子元素;否则,该元素将不可见:

图 15.2:默认图像 UI 元素——一个白色框

  1. 点击场景视图顶部栏中的2D按钮。这将仅更改场景视图的视角,使其更适合编辑 UI(以及 2D 游戏):

包含截图、文本、字体、数字的图片,自动生成描述

图 15.3:2D 按钮位置

  1. 双击层次结构窗口中的 Canvas,使 UI 完全适应场景视图。这将允许我们清楚地编辑 UI。您还可以使用鼠标滚轮导航 UI 进行缩放,并单击并拖动滚轮进行相机平移。

  2. 启用RectTransform工具,这是 Unity 编辑器左上角第五个按钮(或按T键)。这将启用矩形操纵,允许您移动、旋转和缩放 2D 元素。虽然我们迄今为止使用的变换工具与 UI 一起工作,但它们并不理想。一个例子是,改变 UI 元素的大小并不等同于缩放元素;我们希望修改元素的宽度和高度,但保持其缩放以进行动画,防止 9 切片图像变形(关于这一点,本章后面将详细介绍)。

包含文本、截图、设计的图片,自动生成描述

图 15.4:矩形操纵按钮

  1. 使用矩形操纵,拖动对象进行移动,使用蓝色点更改其大小,或将鼠标定位在蓝色点附近,直到光标变成曲线箭头以旋转它。请注意,使用此操纵器调整对象大小并不等同于缩放对象(关于这一点,稍后将详细介绍):

图 15.5:编辑 2D 元素的矩形操纵

  1. 检查器窗口中,注意在更改 UI 元素的大小后,RectTransform设置的缩放属性仍然是111,但您可以看到宽度高度属性是如何变化的。RectTransform本质上是一个经典的变换,但增加了宽度高度(以及其他我们稍后会探索的属性)。您可以在这里设置您想要的精确值,以像素为单位表示:

图 15.6:RectTransform 属性

现在我们已经了解了如何定位任何 UI 对象的基础知识,让我们探索您可以添加到 Canvas 的不同类型的元素。

Canvas 对象类型

到目前为止,我们使用了最简单的 Canvas 对象类型——一个白色框,但还有许多其他对象类型我们可以使用,例如图像、按钮和文本。所有这些都使用RectTransform来定义它们的显示区域,但每个都有其自己的概念和配置需要理解。

在本节中,我们将探索以下 Canvas 对象概念:

  • 集成 UI 的资产

  • 创建 UI 控件

让我们首先探索如何将图像和字体集成到 Canvas 中使用,这样我们就可以使用图像文本UI 对象类型将它们集成到我们的 UI 中。

集成 UI 的资产

在制作我们的 UI 使用漂亮的图形资产之前,我们需要将它们正确集成到 Unity 中,以便在 UI 中使用。在下面的截图中,您将找到我们为游戏提出的 UI 设计:

图 15.7:UI 设计

此外,我们还将添加一个暂停菜单,当用户按下Esc键时将被激活。它看起来如下截图所示:

图 15.8:暂停菜单设计

根据这些设计,我们可以确定我们需要以下资产:

  • 英雄的化身图像

  • 健康条图像

  • 暂停菜单背景图像

  • 暂停菜单按钮图像

  • 文本的字体

和往常一样,我们可以在互联网上或资产商店中找到所需的资产。在我的情况下,我将使用两者的混合。让我们从最简单的一个开始——头像。按照以下步骤操作:

  1. 从互联网上下载您想要的头像,例如一个角色的面部图像。

  2. 将其添加到您的项目中,无论是通过将其拖动到项目窗口,还是通过使用资产 | 导入新资产选项。将其添加到精灵文件夹中。

  3. 选择纹理,并在检查器窗口中,将纹理类型设置设置为精灵(2D 和 UI)。默认情况下,所有纹理都为 3D 使用做好了准备。此选项将我们的纹理准备用于 2D 环境,如 UI 和 2D 游戏。

对于条形、按钮和窗口背景,我将向您展示如何从资源商店中选择正确的资源。这是一个 UI 元素的宝库,我会指出一些宝石。在我的情况下,我发现以下截图中的包非常适合我的 UI。像往常一样,请记住,这个确切的包可能现在不可用。

如果是这样,请记得寻找另一个类似的包,或者从 GitHub 仓库中选择精灵:

视频游戏截图,自动生成描述

图 15.9:选定的 UI 包

首先,该包包含大量配置相同的图像,作为精灵,但我们可以进一步修改导入设置以实现高级行为,这对于按钮是必需的。按钮资源具有固定的大小,但如果您需要一个更大的按钮怎么办?一个选项是使用具有不同大小的其他按钮资源,但这会导致按钮和其他资源(如不同大小的窗口背景)的大量重复,这会不必要地消耗 RAM。

另一个选项是使用9 分割方法,该方法包括将图像分割,使角落与其他部分分离。这允许 Unity 拉伸图像的中间部分以适应不同的大小,同时保持角落的原始大小,当与为 9 分割技术准备的图像结合使用时,可以用来创建几乎任何所需的大小。

图 15.10中,您可以看到左下角有一个九个分割的形状,在相同图表的右下角,您可以看到形状被拉伸但保持了原始大小的角落。右上角显示了没有分割的拉伸形状。您可以看到非分割版本是如何变形的:

包含截图、矩形、正方形、线的图片,自动生成描述

图 15.10:分割与非分割图像拉伸对比

在这种情况下,我们可以将九分割应用于按钮和面板背景图像,以便在游戏的各个部分使用它们。为了做到这一点,请按照以下步骤操作:

  1. 使用窗口 | 包管理器选项打开包管理器。

  2. 通过将窗口左上角+按钮右侧的下拉菜单设置为Unity Registry,验证包管理器是否显示了所有包。

  3. 安装 2D 精灵包以启用精灵编辑工具(如果尚未安装)。

  4. 项目窗口中选择按钮精灵,然后在检查器窗口中点击精灵编辑器按钮:

图 15.11:检查器窗口中的精灵编辑器按钮

  1. 精灵编辑器窗口中,找到并拖动图像边缘的绿色点来移动切片标尺。尽量确保切片不在按钮边缘的中间。要注意的一点是,在我们的情况下,我们将使用三个切片而不是九个,因为我们的按钮不会垂直拉伸。如果你看不到点,尝试点击图像使它们出现。

  2. 注意,在拖动绿色点之后,底右角的边框属性(LTRB,分别是左、上、右和下)发生了变化。这些就是通过移动绿色点设置的精确值。你可以随意将它们改为更圆的数字,以便九个切片均匀工作。在我们的情况下,左右变成了圆整的60,上下变成了50

  3. 点击窗口右上角的应用按钮并关闭它:

图 15.12:精灵编辑器窗口中的九个切片

  1. 背景面板图像重复步骤 46。在我的情况下,你可以在图 15.13中看到这个背景并不是完全按照九个切片来准备的,因为图像的所有中间区域都可以缩小以节省内存。

当以较小的宽度显示此图像时,9 切片方法会拉伸中间部分,看起来相同,所以本质上,这是浪费的内存:

图 15.13:精灵编辑器窗口中的九个切片

通过移除中间部分,图像看起来几乎相同,但节省了大量的内存,如以下图像所示。

图 15.14:优化的 9 切片背景。中间部分将是一个 1 像素宽的像素条,将会扩展

现在我们已经准备好了精灵,我们可以找到一个字体来自定义 UI 的文本。在讨论如何导入字体之前,值得提一下,我们将使用TextMesh Pro,这是一个 Unity 包(已包含在项目中),它提供了一个比旧文本组件更好的文本渲染解决方案。如果你之前从未使用过该组件,你不必担心这个细节。

你必须获取.ttf.otf格式的字体并将它们导入 Unity。互联网上有很多好的免费字体网站。我习惯于使用经典的 DaFont.com 网站,但还有很多其他你可以使用的网站。在我的情况下,我将使用Militech字体:

包含文本、字体、截图、图形的图片,描述自动生成

图 15.15:我在 DaFont.com 上选择的字体,用于项目

如果字体下载包含多个文件,你只需将它们全部拖入 Unity,然后使用你最喜欢的一个。同样,像往常一样,尽量将字体放在名为Fonts的文件夹中。现在,这些文件的格式与我们的文本渲染解决方案 TextMesh Pro 不兼容,因此我们必须使用字体资产创建器窗口进行转换,如下面的步骤所示:

  1. 前往窗口 | TextMesh Pro | 字体资产创建器。

  2. 如果你第一次在你的项目中使用 TextMesh Pro,会出现一个窗口。你必须点击导入 TMP 基础组件选项,并等待导入过程完成:

图片

图 15.16:TextMesh Pro 首次运行初始化

  1. 关闭TMP 导入器窗口。

  2. 字体资产创建器中,将你的字体从项目视图拖到源字体文件,或者通过点击右侧的目标按钮(中心带点的圆圈)来选择它。

  3. 点击生成字体图集按钮,稍等片刻:

图片

图 15.17:将字体资产转换为 TextMesh Pro

  1. 点击保存按钮,并将转换后的字体保存到资产 | TextMesh Pro | 字体文件夹。在这里保存很重要,所以不要忘记选择正确的文件夹:

图片

图 15.18:在正确的文件夹中保存转换后的字体(Mac)

你已经搭建好了基础。让我们添加更多元素,看看我们的 UI 如何变得生动。你在这里学到的技能在你进入后面的章节时将非常有价值。

创建 UI 控件

每个 UI 组件,图像和文本的混合,都需要仔细的配置。让我们深入了解这些元素如何组合在一起形成一个直观的界面。在我们的 UI 中已经有一个图像——我们之前创建的白色矩形。如果你选择它并查看检查器窗口,你会注意到它有一个图像组件,就像以下截图中的那样:

图片

图 15.19:图像组件的检查器窗口

理解这些设置对于最佳的 UI 显示至关重要。我们将从英雄的头像开始,关注如何在我们的 UI 中最好地表示它:

  1. 使用矩形操纵杆,将白色矩形移动到 UI 的左上角:

包含截图、矩形、线条、方形的图片  描述由中等置信度自动生成

图 15.20:位于 UI 左上角的白色矩形

  1. 检查器窗口中,点击源图像属性右侧的圆圈,并选择下载的英雄头像精灵:

计算机屏幕截图  描述由中等置信度自动生成

图 15.21:设置图像组件的精灵

  1. 保持正确的宽高比确保图像在不同屏幕尺寸上看起来一致,避免任何视觉扭曲。一种方法是点击图像组件底部的设置原生大小按钮,使图像使用与原始精灵相同的大小。然而,这样做会使图像变得过大,因此您可以按Shift键修改宽度高度值以减小图像大小。另一种选项是勾选保持宽高比复选框以确保图像适合矩形而不拉伸。在我的情况下,我将使用两者:

计算机屏幕截图,自动生成,中等置信度

图 15.22:保持宽高比和设置原生大小的图像选项

创建直观的生命条对于玩家体验至关重要。让我们通过以下步骤创建生命条:

  1. 使用GameObject|UI|图像选项创建另一个图像组件。

  2. 源图像属性设置为下载的生命条图像:

![图片 B21361_15_23_PE.png]

图 15.23:头像和生命条

  1. 图像类型属性设置为填充

  2. 填充方法属性设置为水平

  3. 拖动填充量滑块以查看根据滑块值如何切割条。我们将在第18章中通过脚本更改该值,性能巫术:使用分析工具优化您的游戏

![图片 B21361_15_24_PE.png]

图 15.24:填充量滑块,切割图像宽度至其大小的 73%

  1. 在我的情况下,条图像还附带了一个条框架,这允许您创建另一个图像,设置精灵,并将其放置在生命条上方以框架它。这里的层次结构顺序会影响这些元素的视觉堆叠。这种定位对于实现所需的视觉效果至关重要。因此,在我的情况下,我需要确保框架 GameObject 在层次结构中位于健康条图像下方。此外,请注意,条框架图像未切片,因此在这种情况下不需要使用切片图像类型。您可以随意尝试切片它并查看结果:

![图片 B21361_15_25_PE.png]

图 15.25:将一个图像放在另一个图像上方以创建框架效果

  1. 重复步骤 16以创建底部的基线条,或者只需复制并粘贴条和框架,并将其放置在屏幕底部:

![图片 B21361_15_26_PE.png]

图 15.26:玩家和玩家基地生命条

  1. 项目窗口中点击+按钮,并选择精灵|正方形选项。这将创建一个具有 4x4 分辨率的简单方形精灵。

  2. 将精灵设置为玩家基础生命条的基础条,而不是下载的条精灵。这次,我们将使用纯白色图像作为条,因为在我的情况下,原始的是红色,将红色图像的颜色调整为绿色是不可能的。然而,白色图像可以被轻松着色。考虑到原始条的具体细节——例如,我原始条中的小阴影在这里将不会出现。

  3. 选择基础生命条并将颜色属性设置为绿色:

图 15.27:一个带有方形精灵和绿色色调的条

  1. 一个可选步骤是将条框图像转换为 9 切片图像,以便我们可以更改原始宽度以适应屏幕。

现在,通过以下步骤添加得分子弹剩余波次剩余敌人标签的文本字段:

  1. 使用GameObject | UI | Text - TextMesh Pro选项创建一个文本标签(避免只说Text的选项)。这将作为得分标签。

  2. 将标签放置在屏幕的右上角。

  3. Inspector窗口中,将Text Input属性的内容设置为Score: 0

  4. 字体大小属性设置为20

  5. 通过单击Font Asset属性右侧的圆圈并选择所需的字体来应用转换后的字体。

  6. 对齐属性中,选择水平右对齐图标(第一行的第三个按钮)和垂直居中对齐图标(第二行的第二个按钮):

图 15.28:文本标签的设置

  1. 重复步骤 16以创建其他三个标签(或者只需复制粘贴得分三次)。对于剩余波次标签,可以使用左对齐选项以更好地匹配原始设计:

图 15.29:我们 UI 中的所有标签

  1. 将所有标签的颜色设置为白色,因为我们的场景将主要是暗色调。

现在我们已经完成了原始 UI 设计,我们可以创建暂停菜单:

  1. 为菜单的背景创建一个Image组件(GameObject | UI | Image)。

  2. 使用我们之前制作的九个切片设置Background面板的精灵。

  3. 如果尚未设置,将Image Type属性设置为Sliced。此模式将应用 9 切片缩放方法以防止角落拉伸。

  4. 有可能无论如何图像都会拉伸角落,这是因为有时角落相对于您使用的RectTransform设置的大小属性相当大,所以 Unity 别无选择,只能这样做。在这种情况下,正确的解决方案是让一位艺术家为您创建适合您游戏的资产,但有时我们并没有这样的选择。这次,我们可以简单地增加精灵文件的每单位像素值,这将减小原始图像的缩放,同时保持其分辨率。在下面的两个截图中,您可以看到具有每单位像素值为100的背景图像,然后再次设置为700

    请记住,只为 9 分割或平铺图像类型做这件事,或者如果您没有艺术家来调整它:

    视频游戏截图  自动生成描述

    图 15.30:顶部,一个大的 9 分割图像在一个小的 RectTransform 组件中,它足够小以至于可以缩小角落,底部,相同的图像,将每单位像素设置为 700

  5. 创建一个TextMesh Pro文本字段,将其放置在您想要在图中显示暂停标签的位置,设置为显示暂停文本,并设置字体。请记住,您可以使用颜色属性更改文本颜色。

  6. 将文本字段拖到背景图像上。Canvas中的父子关系系统工作方式相同——如果您移动父项,子项也会随之移动。想法是,如果我们禁用面板,它也会禁用按钮及其所有内容:

图 15.31:暂停标签

  1. 通过转到GameObject | UI | Button - TextMesh Pro(避免使用只说按钮的那个)创建两个按钮。将它们放置在背景图像上您想要的位置。

  2. 通过将它们拖入层次结构窗口,将它们设置为暂停背景图像的子项。

  3. 选择按钮,并将它们Image组件的源图像属性设置为使用我们之前下载的按钮精灵。如果您遇到之前的问题,请记住我们步骤 4中的每单位像素修复。

  4. 您会看到按钮本质上是一个带有子TextMesh Pro文本对象的图像。将每个按钮和每个文本的字体更改为ResumeQuit

图 15.32:暂停菜单实现

  1. 请记住,您可以通过取消勾选检查器窗口顶部对象名称右侧的复选框来隐藏面板:

计算机菜单截图  自动生成描述,中等置信度

图 15.33:禁用 GameObject

今天,我仍然记得我的第一次游戏测试,当时我把我的游戏交给了一些陌生人,希望用我出色的游戏让他们感到惊讶,结果却发现并非如此。在游戏中你会发现很多缺失的东西,其中之一就是用户体验(也称为 UX)。关键在于理解游戏流程的感觉并改进它。虽然用户体验不仅仅局限于用户界面,但我建议在为你的游戏设计用户界面之前研究这个概念,因为它将帮助用户更好地理解你的游戏以及用户界面试图传达的信息。

在本节中,我们讨论了如何通过图像文本按钮组件导入图像和字体,以创建丰富且信息丰富的 UI。完成这些后,让我们讨论如何使它们具有响应性。

创建响应式用户界面

现在,几乎不可能在单个分辨率下设计 UI,我们的目标受众的显示设备可能会有很大的差异。一台 PC 有多种不同类型的显示器,具有不同的分辨率(如 1080p 和 4K)和纵横比(如 16:9、16:10 和超宽),移动设备也是如此。我们需要准备我们的 UI 以适应最常见的显示,Unity UI 有完成这一任务的工具。

在本节中,我们将探讨以下用户界面响应性概念:

  • 调整对象位置

  • 调整对象大小

我们将探索如何使用画布RectTransform组件的高级功能,如锚点缩放器,来使 UI 元素适应不同的屏幕尺寸。

调整对象位置

为了确保我们的用户界面能够正确地适应不同的设备,让我们专注于使用锚点来调整对象位置。

目前,如果我们运行我们的游戏,我们会看到用户界面如何很好地适应我们的屏幕。然而,如果由于某种原因我们改变了游戏视图的大小,我们会看到对象开始从屏幕上消失。在下面的屏幕截图中,你可以看到不同大小的游戏窗口以及用户界面在一个窗口中看起来很棒,而在其他窗口中则不好:

图片

图 15.34:相同的 UI 但在不同大小的屏幕上

问题在于我们使用编辑器中拥有的任何分辨率创建了 UI,但一旦我们稍微改变它,UI 就保持了之前分辨率的布局。此外,如果你仔细看,你会看到 UI 总是居中的,比如在第二张图片中,UI 在两侧被裁剪,或者在第三张图片中,屏幕边缘可以看到额外的空间。

这是因为 UI 中的每个元素都有自己的锚点,当你选择一个对象时,你可以看到一个小的十字,如下面的屏幕截图所示:

图片

图 15.35:屏幕右上角属于屏幕左上角英雄头像的锚点十字

对象的xy位置是以到该锚点的距离来测量的,锚点相对于屏幕有一个位置,其默认位置是屏幕中心。这意味着在一个800 x 600的屏幕上,锚点将被放置在400 x 300的位置,在一个1920 x 1080的屏幕上,锚点将位于960 x 540的位置。如果元素的xy位置(在RectTransform中的那个)是0,则对象将始终与中心保持 0 的距离。在前三个示例的第二张截图中,英雄头像超出了屏幕范围,因为其与中心的距离大于屏幕的一半,而当前距离是基于之前更大的屏幕尺寸计算的。那么我们能做什么呢?移动锚点!

通过设置相对位置,我们可以将锚点放置在屏幕的不同部分,并使该部分成为我们的参考位置。在我们的英雄头像的情况下,我们可以将锚点放置在屏幕的左上角,以确保我们的头像将始终与该角落保持固定距离。

我们可以通过以下步骤实现:

  1. 选择你的玩家头像。

  2. 如果尚未展开,请在检查器中展开RectTranform组件,以便可以看到其属性。这将揭示场景视图中的锚点。

  3. 使用鼠标将锚点十字形拖动到屏幕的左上角。如果由于某种原因,在拖动锚点时它断裂成几块,撤销更改(按Ctrl + Z,或在 Mac 上按Command + Z)并尝试通过点击中心来拖动它。我们稍后会断开锚点。检查头像图像的RectTransform组件,以验证锚点属性的最小值最大值子属性与图 15.36中的值相同,这意味着对象已正确配置锚点以位于屏幕的左上角:

图片

图 15.36:屏幕左上角带有锚点的图像

  1. 生命条对象的锚点和其框架放在相同的位置。我们希望条形始终与该角落保持相同的距离,以便在屏幕大小改变时它将随英雄头像移动。

  2. Boss Bar对象的锚点放置在屏幕的底部中央,以便它始终居中。稍后我们将处理调整其大小的问题。

  3. 剩余波标签放在左下角,剩余敌人放在右下角:

图片

图 15.37:生命条和标签的锚点

  1. 得分子弹的锚点放在右上角:

图片

图 15.38:得分和子弹标签的锚点

  1. 选择任何元素,并使用鼠标拖动画布矩形的边缘来预览元素将如何适应其位置。请注意,你必须选择 Canvas 的直接子对象;按钮内的文本不会有这个选项:

图 15.39:预览 Canvas 调整大小

现在我们已经将 UI 元素调整到它们的位置,让我们考虑那些对象大小也必须适应的场景。

调整对象大小

处理不同宽高比时首先要考虑的是,我们的屏幕元素不仅可能从它们原始的设计位置(我们在上一节中固定了该位置)移动,还可能无法适应原始设计。在我们的 UI 中,我们遇到了健康条的情况,当我们在更宽的屏幕上预览时,条显然没有适应屏幕宽度。我们可以通过断开锚点来解决这个问题。

当我们断开锚点时,我们的对象的位置和大小是相对于不同锚点部分的距离来计算的。如果我们水平分割锚点,那么我们将有属性,而不是X宽度属性,它们代表左右锚点之间的距离。我们可以用以下方式使用它:

  1. 选择健康条,并将锚点的左侧拖动到屏幕的左侧,右侧拖动到屏幕的右侧。

  2. 对健康条框架也做同样的操作:

图 15.40:健康条中的分割锚点

  1. 检查器窗口中检查矩形变换设置的属性,它们代表到相应锚点的当前距离。如果你想,你可以添加一个特定的值,特别是如果你的健康条显示在屏幕外:

图 15.41:分割锚点的左和右属性

有效地使用 Canvas 缩放器有助于在不同分辨率下保持 UI 元素的比例,这是现代游戏设计的一个关键方面。

这样,对象将始终保持在屏幕相对位置的一个固定距离——在这个例子中,是屏幕的边缘。如果你正在处理子对象,例如按钮的文本图像组件,锚点相对于父对象。如果你注意文本的锚点,它们不仅在水平方向上分割,还在垂直方向上分割。这允许文本根据按钮的大小调整其位置,因此你不需要手动更改它:

图 15.42:按钮文本的分割锚点

现在,这个解决方案并不适用于所有场景。让我们考虑一个英雄头像显示的分辨率高于其设计分辨率的案例。即使头像放置正确,它也会显示得更小,因为屏幕每英寸像素数比分辨率较低的屏幕多,并且物理尺寸相同。你可以考虑使用分割锚点,但宽度和高度锚点在不同宽高比屏幕上可能以不同的方式缩放,因此原始图像会变形。相反,我们可以使用画布缩放器组件。

画布缩放器组件定义了在我们的场景中一个像素代表什么。如果我们的 UI 设计分辨率为 1080p,但我们看到它在一个 4K 显示器上(这意味着水平方向和垂直方向分辨率是 1080p 的两倍,即像素数是四倍),我们可以缩放 UI,使一个像素变为两个像素,调整其大小以保持与原始设计相同的比例大小。基本上,这个想法是,如果屏幕更大,我们的元素也应该更大。

我们可以通过以下方式使用此组件:

  1. 选择画布对象,并在检查器窗口中定位画布缩放器组件。

  2. UI 缩放模式属性设置为随屏幕大小缩放

  3. 如果与艺术家合作,将参考分辨率设置为艺术家创建 UI 的分辨率,同时记住它必须是最高目标设备分辨率(对我们来说并非如此)。在我们的例子中,我们不确定下载的资产艺术家心中所想的分辨率,因此我们可以选择1920 x 1080,这是全高清分辨率大小,并且现在非常常见。

  4. 匹配属性设置为高度。此属性的目的是在执行缩放计算时确定要考虑的分辨率哪一侧。在我们的例子中,如果我们以 1080p 分辨率玩游戏,1 个 UI 像素等于 1 个实际屏幕像素。然而,如果我们以 720p 分辨率玩游戏,1 个 UI 像素将是 0.6 个实际像素,因此元素在较小分辨率的屏幕上会变得更小,保持正确的尺寸。我们没有选择宽度值,因为我们可能遇到极端的屏幕宽度,例如超宽屏幕,如果我们选择了该选项,这些屏幕将不必要地缩放 UI。另一个选项是将此值设置为0.5以考虑两个值,但在 PC 上这并没有太多意义。在移动设备上,你应该根据游戏的方向来选择,为横屏模式设置高度,为竖屏模式设置宽度。

  5. 尝试预览一个更宽更高的屏幕,以查看此设置如何工作:

计算机屏幕截图,自动生成描述,置信度低

图 15.43:适用于标准 PC 游戏的 Canvas 缩放器设置

你会发现你的 UI 将比原始设计更小,这是因为我们本应该在此之前设置这些属性。现在,唯一的解决办法是再次调整所有元素的大小。下次尝试这个练习时要考虑到这一点;我们只遵循这个顺序是为了学习目的。

使你的 UI 适应其内容的另一种方法是使用布局组件,如水平布局。这些组件将自动调整其子元素的大小和位置,以遵循某些规则,例如确保所有元素都相邻。这在像物品列表这样的地方特别有用,你可以在其中添加和删除元素,并期望列表适应这些变化。更多信息,请参阅此文档:docs.unity3d.com/Packages/com.unity.ugui@2.0/manual/comp-UIAutoLayout.html

拥有这些知识后,你现在可以开始编写脚本以反映游戏中的情况来构建用户界面了。

编写 UI 脚本

我们之前创建了一个包含条、文本和按钮等元素的 UI 布局,但到目前为止,它们是静态的。我们需要让它们适应游戏的实际状态。在本节中,我们将讨论以下 UI 脚本概念:

  • 在 UI 中显示信息

  • 编程暂停菜单

我们将首先了解如何使用修改 Canvas 元素显示的文本和图像的脚本来显示和更新 UI 中的动态信息。之后,我们将创建暂停功能,该功能将在整个 UI 中使用。开发暂停功能不仅仅是添加一个功能;它关乎增强用户控制和游戏交互,这是用户体验的关键方面。

带着这种理解,你已经准备好将你的游戏 UI 提升到交互式水平。现在是时候让你的 UI 不仅仅是一个视觉元素,而是成为你游戏叙事的有机组成部分了。

在 UI 中显示信息

我们已经讨论了如何使用 UI 传达关键游戏数据。现在,让我们深入实际方面:使用我们之前创建的Life脚本,实时动态地反映玩家的健康状态:

  1. 将一个名为生命条的新脚本添加到健康条画布子对象中,这是我们之前创建的 UI 图像组件,用于表示生命条:

图 15.44:玩家健康条画布中的生命条组件

  1. LifeBar脚本中,添加了一个Life类型字段。这样,我们的脚本将询问编辑器我们将监控哪个Life组件。保存脚本:

图 15.45:编辑器可配置的生命条组件引用

  1. 在编辑器中,从层次结构窗口拖动PlayerGameObject 到目标生命属性,使生命条引用玩家的生命,记得在拖动Player之前选择HealthBar对象。这样,我们就告诉我们的LifeBar脚本检查哪个生命组件以查看玩家剩余的生命。这里有趣的是,敌人也有相同的生命组件,因此我们可以轻松地使用这个组件为游戏中所有有生命的其他对象创建生命条:

计算机屏幕截图,描述自动生成,置信度低

图 15.46:拖动玩家以引用其生命组件

  1. 在脚本的前几行using语句之后添加using UnityEngine.UI;行。这将告诉 C#我们将与 UI 脚本交互:

图片

图 15.47:我们脚本中的所有使用语句。我们不会使用它们全部,但现在让我们保留它们

  1. 创建一个private类型的Image字段(不使用public关键字)。我们很快就会在这里保存组件的引用:

图片

图 15.48:对图像的私有引用

  1. Awake中使用GetComponent获取我们 GameObject(HealthBar)中的Image组件引用,并将其保存在image字段中。通常,我们的想法是只获取这个引用一次,并在Update函数中稍后使用。当然,当你把这个组件放在一个带有Image组件的对象中时,这总是可行的。如果不是这样,另一种选择是创建一个Image类型的公共字段,并将图像组件拖入其中:

包含文本、字体、屏幕截图、行描述的图片,自动生成

图 15.49:在此对象中保存 Image 组件的引用

  1. 让我们在LifeBar脚本中设置一个Update事件函数。这个函数将是我们的 UI 更新逻辑的核心,使生命条与玩家的健康保持同步。

  2. Update事件中,将生命量除以100,以便将当前生命百分比表示在01的范围内(假设最大生命值为100),并将结果设置在Image组件的fillAmount字段中,如以下截图所示。请记住,fillAmount期望一个介于01之间的值,其中0表示条是空的,1表示条已满:包含文本、字体、屏幕截图、行描述的图片,自动生成

    图 15.50:根据生命组件更新 LifeBar 脚本 Image 组件的填充量

    在代码中直接使用特定的数字,如 100,是硬编码的经典例子。为了保持灵活性,考虑引用Maximum Life字段或使用一个常量。

  3. 保存脚本,并在编辑器中选择玩家并开始游戏。在Play模式下,按Esc键以恢复鼠标访问权限,并在Inspector窗口中更改玩家的健康值以查看生命条如何相应更新。你也可以通过让玩家以某种方式受到伤害来测试这一点,例如让敌人发射子弹(稍后会有更多关于敌人的内容):

图片

图 15.51:完整的 LifeBar 脚本

在上一章中,我们探讨了事件的概念,用于检测其他对象状态的变化。生命条是使用事件的另一个例子,因为当生命值实际变化时,我们可以改变图像的填充量。我挑战你尝试在生命值变化时创建一个事件,并使用我们在上一章中查看的脚本实现此脚本。

你可能会想,这种 UI 行为可以直接在Life组件中编码,这是完全可能的,但这里的想法是创建简单的脚本,减少对代码的压力,使代码分离。每个脚本应该只有一个修改的理由,将 UI 行为和游戏行为混合在单个脚本中会给脚本两个责任,从而导致两个可能改变脚本的原因。采用这种方法,我们也可以通过将相同的脚本添加到玩家的生命条上,并将我们在上一章中创建的Base Damage对象作为这次的目标生命值,来设置玩家的基础生命条在底部。

对于使用视觉脚本的玩家,以下是你需要集成的分解。这是将我们的 C#逻辑转换为一个更直观、基于节点的格式:

图片

图 15.52:完整的 LifeBar 视觉图表

首先,我们在生命条图片的变量组件中添加了一个类型为GameObjecttargetLife变量。然后,我们将我们的Player GameObject(目前称为Robot)拖拽到这个变量上;生命条现在有了我们想要显示其生命值的对象的引用。接着,我们添加了一个LifeBar视觉图表;在Update节点中,它调用Set Fill Amount节点来更新图像的填充量。记住,在这种情况下,仅仅调用Set Fill Amount节点就会显示我们引用的是这个视觉图表所在的位置的图像组件,因此在这里不需要使用GetComponent。为了计算填充量,我们获取targetLife GameObject 的引用,并使用第二个Get Variable节点提取该对象的生命变量。最后,我们将这个值除以 100(我们需要创建一个Float Literal节点来表示值100)并将结果传递给Set Fill Amount节点。像往常一样,你可以在 GitHub 仓库中查看完整的版本。

我们在这里应用的原则——单一对象职责——是面向对象编程中更广泛的SOLID原则的一部分。这些原则是构建健壮和可扩展代码架构的基础。如果您不知道 SOLID 是什么,我强烈建议您在网上搜索SOLID 编程原则以提高您的编程最佳实践。

现在我们已经整理好了玩家的生命条,让我们让Bullets标签根据玩家的剩余子弹更新。这里需要考虑的是,我们当前的PlayerShooting脚本具有无限子弹,所以让我们通过以下步骤来改变这一点:

  1. PlayerShooting脚本中添加一个公共的int类型字段,命名为bulletsAmount

  2. 在检查左鼠标按钮压力的if语句中,添加一个条件来检查子弹数量是否大于0

  3. if语句中,将子弹数量减少1

图片

图 15.53:限制射击子弹的数量

在视觉脚本版本中,PlayerShooting视觉图的修改后的射击条件将看起来像这样:

图片

图 15.54:仅在子弹可用时射击,并在射击后减少子弹数量

如您所见,我们只是检查我们添加的新子弹变量是否大于零,然后使用If节点条件来执行Instantiate节点。子弹递减将看起来像这样:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 15.55:在视觉图中递减子弹计数

我们只是从子弹变量中减去一个,然后用这个值再次设置子弹。

在子弹计数器就位后,我们的下一步是将这个元素在 UI 中激活。可视化子弹计数不仅是一个功能,而且是游戏玩法的一个基本方面,它使玩家在战术上保持警觉:

  1. PlayerBulletsUI脚本添加到子弹的TextGameObject 中。在我的例子中,我将其命名为Bullets Label

  2. 在文件开头添加using TMPro;语句,因为我们将会修改标签的TextMesh Pro组件。

  3. 添加一个TMP_Text类型的私有字段,在Awake中将它保存到Text组件的引用中:

图形用户界面、文本、应用程序、电子邮件  自动生成的描述

图 15.56:缓存 Text 组件的引用

  1. 创建一个名为targetShootingPlayerShooting类型的公共字段,并在编辑器中将Player拖到这个属性上。与LifeBar组件的情况一样,我们的想法是 UI 脚本将访问具有剩余子弹的脚本以更新文本,连接两个脚本(TextPlayerShooting),以保持它们职责的分离。

  2. 创建一个Update语句,并在其中设置文本引用的text字段(我知道,有点混乱)为"Bullets: "targetShooting引用的bulletsAmount字段的连接。这样,我们将根据当前子弹数量替换标签的文本:

包含图表的图片,描述自动生成

图 15.57:更新子弹文本标签

需要记住的一个关键编码原则是字符串连接会分配内存。为了保持最佳性能,仅在必要时连接字符串——这是游戏开发中内存管理的策略性方法。

关于视觉脚本,在实际上设置文本之前,我们需要在视觉脚本中添加对 TextMesh Pro 的支持。视觉脚本需要手动指定我们将要使用的 Unity 系统和包,由于 TextMesh Pro 不是严格的核心 Unity 功能,它可能默认不包含。我们可以通过以下方式在视觉脚本中添加对 TextMesh Pro 的支持:

  1. 前往编辑 | 项目设置并选择视觉脚本类别。

  2. 使用左侧的箭头展开节点库选项。

  3. 检查列表中是否有Unity.TextMesh Pro。如果有,请随意跳过以下步骤。

  4. 使用列表底部的+按钮添加一个新的库。

  5. 点击显示为(无需组装)的位置,并搜索Unity.TextMesh Pro

  6. 点击重新生成节点按钮,等待直到生成过程完成:

图 15.58:将 TextMesh Pro 支持添加到视觉脚本

完成这些设置后,这将是要添加到子弹文本 GameObject 的视觉图:

图 15.59:在视觉脚本中更新子弹文本标签

如同往常,我们需要一个对玩家的引用来检查其子弹,所以我们创建了一个类型为GameObjecttargetBullets变量并将玩家拖放到那里。然后,我们使用获取变量节点从该引用中提取子弹数量,并使用字符串字面量节点和连接节点将字符串"Bullets: "与子弹数量连接起来。该节点将执行与我们在 C#中使用+运算符将两个字符串连接起来时相同的功能。最后,我们使用设置文本(源文本,同步文本输入框)节点来更新文本字段的文本。

如果你查看这两个脚本,你会找到一个模式。你可以访问UI游戏玩法组件,并相应地更新UI组件,并且大多数 UI 脚本的行为方式相同。记住这一点,我挑战你创建必要的脚本,使得分敌人计数器工作。记得添加using TMPro;以使用TMP_Text组件。

完成此操作后,你可以将你的解决方案与以下截图中的解决方案进行比较,从ScoreUI开始:

图形用户界面,文本,应用程序,自动生成描述

图 15.60:ScoreUI 脚本

此外,我们还需要WavesUI组件:

图形用户界面,文本,应用程序,电子邮件,自动生成描述

图 15.61:WavesUI 脚本

最后,我们需要EnemiesUI

图形用户界面,文本,应用程序,电子邮件,自动生成描述

图 15.62:EnemiesUI 脚本

注意我们是如何利用WavesManagerEnemyManager脚本中存在的onChanged事件的存在,仅在需要时更新文本字段。同时,注意我们不需要拖动引用来获取要显示的值,因为这些脚本都使用管理者来获取这些信息。

关于视觉脚本,我们有ScoreUI脚本:

图形用户界面,文本,应用程序,聊天或文本消息,自动生成描述

图 15.63:ScoreUI 可视化脚本

然后,WavesUI脚本:

图形用户界面,应用程序,自动生成描述

图 15.64:WavesUI 可视化脚本

最后,EnemiesUI脚本:

图形用户界面,应用程序,自动生成描述

图 15.65:EnemiesUI 可视化脚本

如您所见,我们已经使用在管理者中已经编码的事件来仅在必要时更改 UI。同时,注意我们是如何使用场景变量来获取要显示的信息。有了我们的 UI 标签和条形图就绪,我们的下一步是将暂停菜单激活。这涉及到编写增强游戏交互性的脚本。

编程暂停菜单

让我们回顾一下之前设计的暂停菜单,它已经准备好被激活。编写暂停功能可能看起来很复杂,但采用战略方法,我们可以简化这个过程。所以,我们再次将使用简单的方法暂停大多数行为,即停止时间!记住,我们的大多数移动脚本使用时间功能,如Delta Time(我们在第二章构建场景和游戏元素中讨论过),作为计算要应用的运动量的方式。还有一种方法可以模拟时间变慢或变快,那就是通过设置timeScale。这个字段将影响 Unity 的时间系统速度,我们可以将其设置为0来模拟时间已停止,这将暂停动画,停止粒子,并将Delta Time减少到0,使我们的动作停止。所以,让我们来做吧:

  1. 创建一个名为Pause的脚本,并将其添加到一个名为Pause的新 GameObject 中。

  2. 在脚本文件开头添加using UnityEngine.InputSystem;语句,以便能够读取输入。

  3. Update 中检测 Esc 键是否被按下。我们可以向我们的 Player Input 资产文件中添加一个映射并读取输入,就像我们在 第二章构建场景和游戏元素 中做的那样,但为了学习使用输入系统的新方法,我们将使用 Keyboard.current 变量直接在 Update 方法中读取键的状态,而不是使用映射。请考虑,始终推荐使用输入映射,但让我们这样做以供学习。当按下 Esc 键时,您可以设置 Time.timeScale 变量为 0,如下面的图像所示:

图形用户界面、文本、应用程序、电子邮件  自动生成的描述

图 15.66:停止时间以模拟暂停

  1. 通过玩游戏并按下 Esc 键来保存并测试此操作。您会注意到几乎一切都会停止,比如角色移动的动画,但您可以看到射击功能仍然正常工作。这是因为 PlayerShooting 脚本不是时间依赖的。这里的一个解决方案可能是简单地检查 Time.timeScale 是否大于 0 以防止这种情况:

图形用户界面、文本、应用程序  自动生成的描述

图 15.67:在玩家射击脚本中检查暂停

  1. 在我们的 EnemyFSM Shoot 方法中也需要做同样的事情。

如往常一样,我们在这里追求最简单的方法,但有一个更好的方法。我挑战你尝试创建一个带有布尔值的 PauseManager,指示游戏是否已暂停,并在过程中更改 timeScale

现在我们已经找到了一种简单但有效的方法来暂停游戏,让我们通过以下步骤使暂停菜单可见,以便通过以下操作恢复游戏:

  1. Pause 脚本中添加一个名为 pauseMenuGameObject 类型的字段。想法是将暂停菜单拖到这里,以便我们有一个引用来启用和禁用它。

  2. Awake 中添加 pauseMenu.SetActive(false); 以在游戏开始时禁用暂停菜单。即使我们在编辑器中禁用了暂停菜单,我们也添加这个,以防我们不小心重新启用它。它必须始终以禁用状态开始。

  3. 使用相同的函数,但将第一个参数传递为 true,在 Esc 键按下检查中启用暂停菜单:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 15.68:按下 Esc 键时启用暂停菜单

现在,我们需要使暂停菜单按钮工作。如果您还记得,我们探讨了事件的概念,通过不同的管理器使用 UnityEvents 来实现。我们的 暂停菜单按钮使用相同的类来实现 onClick 事件,这是一个通知我们特定按钮已被按下的事件。让我们通过以下操作在按下这些按钮时恢复游戏:

  1. 在我们的Pause脚本中创建一个Button类型的字段,命名为resumeButton,并将resumeButton拖拽到该字段上;这样,我们的Pause脚本就有一个按钮的引用。

  2. Awake中,向resumeButtononClick事件添加一个名为OnResumePressed的监听函数。

  3. 使OnResumePressed函数将timeScale设置为1并禁用Pause菜单,就像我们在Awake中做的那样:

Graphical user interface, text, application, chat or text message  Description automatically generated

图 15.69:取消暂停游戏

如果您保存并测试此操作,您将注意到您无法点击Resume按钮,因为我们已经在游戏开始时禁用了光标,所以请确保在Pause模式下重新启用它,并在恢复时禁用它:

Graphical user interface, text, application  Description automatically generated

图 15.70:在暂停模式下显示和隐藏光标

最后要考虑的一件事是我们希望在OnDestroy方法中将时间尺度重新设置为1。此方法在Pause对象被销毁时执行,这将在我们通过脚本手动销毁对象时发生,或者在这种情况下最重要的是,如果我们更改场景。我们的想法是确保在Pause菜单中更改场景时恢复时间系统,以便下一个场景可以正确地播放游戏:

Graphical user interface  Description automatically generated with medium confidence

图 15.71:离开场景时重置时间尺度

关于Pause脚本的视觉脚本版本,请注意我们没有Keyboard.current的等价物,因此我们需要使用输入映射来替换。为了为Esc键添加输入映射,请执行以下操作:

  1. 双击Player Input资源来编辑它。您可以通过选择Player游戏对象,然后点击检查器中PlayerInput组件的Actions属性右侧的框来找到它。

  2. 使用Actions列表(中间列表)右上角的+按钮创建一个名为Pause的新动作:

Graphical user interface, application, table  Description automatically generated

图 15.72:创建新的输入映射

  1. 点击我们刚刚创建的Pause动作中的项(在其下方)。

  2. 在屏幕右侧的Binding Properties部分(屏幕右侧)的Path属性中,点击其左侧的空矩形,并搜索并选择Escape [Keyboard]按钮:

Graphical user interface, application  Description automatically generated

图 15.73:将键添加到映射

  1. 点击屏幕顶部中间部分的Save Asset按钮。

现在,您可以添加以下图表,这次是添加到Player游戏对象上,因为我们需要从它读取输入:

![img/B21361_15_74_PE.png]

图 15.74:按下 Esc 键时暂停

到目前为止,没有什么新的;我们检测到按下Esc键,当它发生时,我们调用设置时间缩放并指定0值。然后,我们激活暂停菜单(通过在变量组件中的变量pauseMenu进行引用),并启用光标。最后,当对象被销毁时,我们将时间缩放设置为1

关于恢复行为,要添加到相同暂停图表中的节点将看起来像这样:

图形用户界面,文本,应用程序,自动生成的描述

图 15.75:点击“恢复”按钮时暂停

在这个图表中唯一的新元素是点击按钮节点的使用。正如你所预期的那样,这个节点是一个事件,任何连接到它的东西都会在按钮的压力下执行。指定我们指的是哪个按钮的方法是将按钮引用变量连接到点击按钮的输入引脚。你可以看到我们如何在变量组件中创建了一个名为resumeButton按钮类型的变量来完成这个操作。

现在你已经知道了如何编写按钮的代码,我挑战你编写退出按钮的行为。再次提醒,记得添加using UnityEngine.UI。此外,你需要调用Application.Quit();来退出游戏,但请注意,在编辑器中这不会做任何事情;我们不想在创建游戏时关闭编辑器。这个函数只有在构建游戏时才有效。

所以,现在就调用它吧,如果你想打印一条消息来确保按钮正常工作,你可以;以下截图提供了一个解决方案:

图形用户界面,文本,应用程序,聊天或文本消息,自动生成的描述

图 15.76:退出按钮的脚本

这个解决方案建议你直接将此脚本添加到退出按钮的 GameObject 本身,以便脚本监听其Button兄弟组件上的onClick事件,并在接收到事件时执行Quit函数。你还可以将此行为添加到Pause脚本中,虽然这会起作用,但请记住,如果一个脚本可以分成两个,因为它执行两个不相关的任务,那么最好将其拆分,以便分离的行为不相关。在这里,暂停行为与退出行为不相关。

关于视觉脚本版本,要添加到退出按钮的图表将看起来像这样:

图形用户界面,应用程序,自动生成的描述

图 15.77:退出按钮的视觉脚本

简单,对吧?因为我们把它放在按钮本身中,所以我们甚至不需要指定是哪个按钮,因为它会自动检测我们指的是自己。

现在我们已经使用 UI 和按钮设置了暂停系统,我们准备进入下一章,我们将继续探讨其他视觉和听觉方式,让玩家意识到发生了什么。

概述

在本章中,我们介绍了 UI 的基础知识,理解CanvasRectTransform组件以在屏幕上定位对象并创建 UI 布局。我们还涵盖了不同类型的 UI 元素,主要是图像文本,以使我们的 UI 布局生动起来,并吸引用户。最后,我们讨论了如何使 UI 对象适应不同的分辨率和宽高比,使我们的 UI 能够适应不同的屏幕尺寸,尽管我们无法预测用户将使用哪种确切显示器玩游戏。所有这些使我们能够使用 Canvas 创建游戏中需要的任何 UI。

在下一章中,我们将探讨如何使用 UI Toolkit 来创建 UI,这是 Unity 系统中另一种用于创建 UI 的工具,并将 Canvas 和 UI Toolkit 进行比较,以了解何时使用哪一个。

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第十六章:新一代 UI:使用 UI Toolkit 创建动态界面

在上一章中,我们讨论了如何使用 uGUI(也称为 Canvas),这是最常用的 Unity UI 系统之一,但正如我们已经提到的,这并不是唯一的选择。虽然到目前为止,uGUI 一直是首选选项,但 Unity 正在开发一个名为 UI Toolkit 的替代品,即使它还没有与 uGUI 具有相同的功能性,我们认为在本书中介绍它也是值得的。

本章的目的是创建我们之前创建的相同 UI,但使用 UI Toolkit,这样你就可以了解在 Unity 中创建 UI 将会是什么样子。

在本章中,我们将检查以下 UI 概念:

  • 为什么学习如何使用 UI 工具包?

  • 使用 UI Toolkit 创建 UI

  • 使用 UI Toolkit 制作响应式 UI

到本章结束时,你将学习如何使用 UI Toolkit 为我们的游戏创建基本的 UI,作为参考重新制作上一章中的 UI。所以,让我们首先回答以下问题:为什么学习如何使用 UI Toolkit?

为什么学习如何使用 UI Toolkit?

我知道本章的主题可能听起来有点令人困惑;我们刚刚学习了如何使用整个 Unity 系统来创建我们的 UI,现在我们正在学习如何使用另一个!为什么我们必须学习这个新系统?

学习这两个系统的其中一个原因是因为 UI Toolkit 虽然很有前途,但在功能完整性方面还没有达到 uGUI 的水平,这对于现实世界的生产至关重要。另一个需要考虑的事情是,即使 UI Toolkit 已经足够稳定,它仍然是一个相对较新的系统,还有很多游戏是在不支持它的旧 Unity 版本上创建的。这意味着为了在这个行业中找到工作,我们需要对 uGUI 有足够的了解,因为大多数游戏都是使用这项技术制作的。

这是因为用新技术更新已经测试并正常工作的游戏并不安全或不切实际;这些更改可能会导致游戏进行大规模的重做以兼容新版本。此外,这可能会引入大量的错误,从而延迟新版本的发布——更不用说重新用新系统制作整个应用所需的时间了。

话虽如此,我们认为学习 UI Toolkit 的基本概念仍然值得,以便为在新版 Unity 中使用它做好准备,所以现在让我们深入探讨。

使用 UI Toolkit 创建 UI

在本节中,我们将了解 UI 文档,这是 UI Toolkit 的基石。这些资产对于定义你的 UI 结构和组件至关重要,我们将学习如何有效地创建和使用它们。为此,我们将讨论以下概念:

  • 创建 UI 文档

  • 编辑 UI 文档

  • 创建 UI 样式表

让我们先看看我们如何创建我们的第一个 UI 文档。

创建 UI 文档

当使用 uGUI 创建 UI 时,我们需要创建 GameObject 并附加按钮图像文本等组件,但使用 UI Toolkit,我们需要创建一个UI 文档。UI 文档是一种特殊的资产,它将包含我们 UI 将有的元素及其层次结构。我们将有一个具有UI 文档组件的 GameObject(是的,它叫法相同,所以请注意这里)将引用此UI 文档资产并渲染其内容。它就像一个包含有关网格和将要渲染它的 MeshRenderer 组件信息的网格资产。在这种情况下,要渲染的元素包含在一个资产中,我们有一个读取资产并渲染其内容(在这种情况下是 UI)的组件。

有趣的是,UI 文档在其核心是纯文本文件。这意味着您可以使用任何标准文本编辑器打开和检查它们,这提供了一种简单的方法来理解它们的结构。如果您这样做并且您熟悉 HTML,您将识别出用于定义我们 UI 将由其组成的元素的 XML 类似格式;Unity 将此格式称为 UXML。使用 UI Toolkit,Unity 正在尝试让网页开发者更容易地进入 Unity 并创建 UI。在以下代码中,您可以看到 UXML 文档文件内容的典型外观:

<ui:UXML>

   xsi="http://www.w3.org/2001/XMLSchema-instance"
   engine="UnityEngine.UIElements"
   editor="UnityEditor.UIElements"
   noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
   editor-extension-mode="False">
    <ui:Button tabindex="-1" text="Button"
              display-tooltip-when-elided="true" />
    <ui:Scroller high-value="100"
                direction="Horizontal"
                 value="42" />
    <ui:VisualElement>
        <ui:Label tabindex="-1"
                 text="Label"
                 display-tooltip-when-elided="true" />
        <ui:Label tabindex="-1"
                 text="Label"
                 display-tooltip-when-elided="true" />
    </ui:VisualElement>
</ui:UXML> 

如果您不知道 XML,我们将在本章中解释核心概念。另外,不要担心 UXML 格式;在本章的后面部分,我们将使用一个名为UI Builder的视觉编辑器来编辑我们的 UI,而无需编写任何 UXML,但了解它是如何实际工作的仍然很有价值。

为了创建 UI 文档并将其添加到场景中,我们需要执行以下操作:

  1. 项目视图中点击+ | UI Toolkit | UI 文档选项来创建一个UI 文档资产,并将其命名为 GameHUD

计算机屏幕截图  描述由中等置信度自动生成

图 16.1:创建 UI 文档资产

  1. 点击游戏对象 | UI Toolkit | UI 文档选项在您的场景中创建一个具有UI 文档组件的 GameObject,该组件能够渲染 UI 文档。

  2. 选择它,并将GameHUD UI 文档资产(在步骤 1中创建的)拖动到UI 文档GameObject(在步骤 2中创建的)的源资产属性:

图 16.2:使 UI 文档组件渲染我们的 UI 文档资产

就这样!当然,由于 UI 文档是空的,我们屏幕上目前不会显示任何内容,所以让我们开始向其中添加元素。

编辑 UI 文档

由于我们的目标是重新创建我们在上一章中创建的相同 UI,让我们从最简单的一部分开始:将玩家头像添加到左上角。一个选择是使用任何文本编辑器打开UI 文档资产并开始编写 UXML 代码,但幸运的是,我们有一个更简单的方法,那就是使用UI 构建器编辑器。这个编辑器允许我们通过拖放元素来可视地生成 UXML 代码。

在我们深入之前,让我们快速浏览一下 UI 构建器窗口,看看我们正在处理什么:

  1. 项目视图中双击GameHUD资产以使UI 构建器打开它:

![img/B21361_16_03.png]

图 16.3:UI 构建器编辑器

  1. 在 UI 构建器内部的层次结构面板中(不是我们在前几章中使用的那个层次结构面板),选择GameHUD.uxml,这是 UI 的容器元素。

计算机屏幕截图  描述自动生成,置信度低

图 16.4:在层次结构中选择资产名称以编辑通用 UI 设置

  1. 看看 UI 构建器窗口右侧的检查器面板(不是我们之前用来修改 GameObject 的检查器)。将大小属性设置为宽度1920高度1080。这将允许我们查看我们的 UI 在这个分辨率下的外观。您稍后可以更改此值以查看它如何适应不同的大小,但关于这一点稍后再说:

计算机屏幕截图  描述自动生成,置信度中等

图 16.5:设置预览 UI 分辨率

  1. 您可以通过按住鼠标滚轮按钮(也称为中间按钮)并移动鼠标来平移视口以在 UI 中导航。在 Mac 上,您还可以按住Option + Command并点击并拖动视口的任何空闲区域(没有我们的 UI 的地方)来完成相同的操作。

  2. 您还可以使用鼠标滚轮来放大和缩小。最后,您可以使用视口左上角的缩放百分比选择和适应画布按钮来自动将整个 UI 适应到您的视口中:

![img/B21361_16_06_PE.png]

图 16.6:设置预览缩放

现在我们已经了解了 UI 构建器的基础知识,让我们将我们的图像添加到 UI 中:

  1. VisualElement图标从底部的拖到左侧的层次结构部分。这将创建一个基本的 UI 元素,能够渲染图像以及更多内容:

计算机屏幕截图  描述自动生成,置信度中等

图 16.7:创建视觉元素

  1. 层次结构中选择VisualElement(在GameHUD.uxml下)并查看 UI 构建器窗口右侧的检查器(再次,不是常规的 Unity 检查器面板)中的位置部分。如果尚未展开,请展开它(使用左侧的箭头)。

  2. 位置设置为绝对,以便我们可以自由地在 UI 中移动我们的元素。在本章的使用相对位置部分,我们将解释相对模式的工作原理:

包含文本、截图、字体、行的图片,自动生成描述

图 16.8:设置我们的 UI 元素可以自由移动

  1. 打开大小部分,将宽度高度设置为100,使我们的 UI 元素具有非零大小。这样,我们就可以在视口中看到其区域:

计算机屏幕截图,自动生成描述,置信度低

图 16.9:设置我们的 UI 元素大小

  1. 视口面板中,您可以拖动您的元素,并使用角落的蓝色矩形来更改其大小。将您的元素放置在 UI 的左上角。如果您在视口中看不到您的元素,请在层次结构(UI Builder 的那个)中选择它:

图 16.10:移动 VisualElement

  1. 为了设置精确的位置,您可以将检查器位置部分的值设置为分别指定精确的xy坐标,以像素为单位:

图 16.11:设置位置

  1. 检查器背景部分,使用图像属性右侧的组合框将图像模式设置为精灵。这允许我们将精灵作为我们元素的背景应用。

  2. 将我们在第十五章中导入的玩家头像的精灵资产(图像)从项目面板拖动到图像属性以设置它。您还可以使用目标按钮(中间有点的圆形按钮)从拾取窗口中选择精灵资产:

计算机屏幕截图,自动生成描述,置信度中等

图 16.12:设置元素的背景图像

  1. 返回到常规游戏面板查看结果。如果您没有看到变化,您可以关闭并重新打开渲染我们 UI(即我们使用 UI 文档创建的那个)的 GameObject。

现在我们已经创建了玩家头像,我们可以通过以下步骤创建玩家生命值条:

  1. 重复之前的步骤1 到6,创建一个新的元素,该元素将作为玩家生命值条的容器。它将没有图像,因为它只是其他将组成生命值条的元素的容器。

  2. 将其放置在玩家头像旁边,并设置宽度和高度以类似于经典的生命值条。请记住,您可以通过拖动图像和角落的方块或通过大小位置属性来完成此操作,就像我们之前做的那样。

  3. 拖动一个新的 VisualElement 到层次结构中,就像我们在步骤 1中做的那样,但这次,将其拖放到步骤 1中创建的元素上。这将使这个新元素成为它的子元素,这将使该元素的位置和大小依赖于其父元素,就像我们在第十五章界面光辉:设计用户友好的 UI中为 Canvas 对象设置父元素时发生的情况一样。

  4. 选择父 VisualElement,并在检查器中设置名称属性为PlayerHealth以方便识别。对子元素也进行相同的操作,将其命名为Filling

包含文本、字体、截图、白色,描述自动生成

图 16.13:设置视觉元素的父级和命名

  1. 层次结构中选择填充元素,并查看检查器

  2. 背景部分,将颜色属性设置为红色,点击颜色框并使用颜色选择器。这将用纯红色填充我们的 UI 元素背景,而不是使用图像:

包含文本、截图、字体、数字的图片,描述自动生成

图 16.14:为我们的元素设置纯红色背景

  1. 如往常一样,将位置设置为绝对,并将属性设置为0。由于这是一个元素的子元素,位置将相对于其父元素的位置,因此通过指定值为0,我们表示我们将位于父元素的左侧和顶部 0 像素处。这意味着如果父元素移动,此子元素将随之一同移动。

  2. 宽度高度设置为100,并通过点击px按钮并选择%来更改度量单位。这将使填充元素的大小与其父元素相同(父元素大小的 100%):

计算机屏幕截图,描述自动生成,中等置信度

图 16.15:将我们的尺寸设置为与父元素相同的尺寸

  1. 将一个新的VisualElement作为PlayerHealth填充的兄弟元素)的子元素添加,并将其命名为Border

  2. 填充元素在步骤 7步骤 8中设置的位置大小应用于填充元素,但不要设置背景颜色。

  3. 背景部分的图像属性设置为我们在上一章中使用的相同边框图像。请记住将图像模式设置为精灵而不是纹理

  4. 背景部分将切片属性设置为15。这应用了我们在第十五章界面光辉:设计用户友好的 UI中使用的九宫格技术,在不拉伸对象的情况下扩展对象。在这种情况下,我们给切片从 UI 元素的外边框提供 15 像素的大小。

计算机屏幕截图,描述自动生成,中等置信度

图 16.16:在元素中直接设置九宫格大小

  1. Hierarchy 中选择 Filling 视觉元素,并将其 Size 部分的 Width 属性设置为模拟我们在 第十一章迷人的视觉效果:利用粒子系统和 VFX 图 中使用的图像的 Fill Amount 属性。稍后,我们将通过代码将大小直接与玩家的生命值数字成比例:

包含截图和设计的图片,自动生成描述

图 16.17:生命条结果

  1. 重复 步骤 112 来创建 Base Health 条的底部。记住,这次填充必须是绿色的。或者,你也可以直接复制和粘贴 PlayerHealth 容器,但我建议你为了学习目的重复这些步骤。

在之前的步骤中,我们基本上看到了如何组合几个 UI 元素来创建一个复杂对象。我们需要一个父容器元素来驱动我们子元素的大小,以便内部元素适应它,特别是填充,它需要一个百分比值来表示当前玩家的生命值。

现在,我们有了生命条!嗯,还不完全是这样;那些填充中未被边框覆盖的红角看起来相当粗糙!我们将在本章后面讨论如何使我们的 UI 响应时改进这一点,所以现在让我们保持原样。

最后,让我们向 UI 添加文本元素。但首先,我们需要考虑字体。如果你下载了 TTF 字体,你需要创建一个字体资产,就像我们在 第十五章界面辉煌:设计用户友好的 UI 中所做的那样,以便在 UI Toolkit 中使用。然而,根据 UI Toolkit 的当前版本,我们在上一章中创建的字体资产不兼容。

我们需要使用 UI Toolkit 字体资产创建器而不是 TextMesh Pro 创建器来创建字体资产。存在重复工具的原因是 Unity 正在将 Text Mesh Pro 包集成到一个新的、改进的包中,称为 TextCore,其中一项改进是与 UI Toolkit 和其他 Unity 系统的兼容性。

考虑到这一点,为了将 TTF 转换为与 UI Toolkit 兼容的字体资产,你只需在 Project 面板中右键单击 TTF 资产,然后选择 Create | Text | Font Asset。这将创建一个新的资产,我们将使用它来定义 UI Toolkit 文本的字体。

解决了这个问题后,让我们创建文本 UI 元素,即 Label

  1. 将 UI Builder 窗口的 Library 面板中的 Label 图标拖动到其 Hierarchy 面板。这将添加一个 UI 元素,它不仅能够在其背景中渲染图像,还可以显示文本(是的,如果你想的话,你还可以为文本添加背景)。

  2. 如同往常,设置其 位置大小,这次将其放置在屏幕的右上角。记住,你可以简单地拖动元素;你不需要手动设置特定的坐标(尽管如果你愿意,你也可以这样做)。

  3. Inspector标签 部分的 文本 属性更改为所需的文本;在我们的例子中,这将变为 得分:0

图 16.18:设置要显示的文本

  1. 将这些步骤之前创建的 字体 资产拖动到 Inspector文本 部分的 字体资产 属性。不要将其与 字体 属性(位于 字体资产 上方)混淆。那个属性允许你直接拖动 TTF 资产,但这个功能很快就会被弃用,所以让我们坚持使用 Unity 推荐的方法。

  2. 如果你注意到你的 字体 资产不起作用,请尝试将其放入 UI Toolkit | 资源 | 字体与材质 文件夹中的 项目 面板。虽然这不应该在最新的 Unity 版本中是必要的,但我注意到这过去已经解决了这类问题。此外,还有一个错误有时会导致字体无法识别,可以通过删除并重新创建 标签 来修复。

  3. 文本 部分的 大小 属性设置为任何看起来合适的大小:

图 16.19:设置标签的文本字体和大小

  1. 重复 步骤 16 以将所有剩余的 标签 添加到 UI 中。

  2. 我们最后需要做的一件事是保存,这可以通过按 Ctrl + S (在 Mac 上为 Command + S)或使用 UI Builder 窗口 视口 部分的右上角 文件 | 保存 菜单来完成。请注意,UI Toolkit 的早期版本中有一个错误,这可能会使视口损坏。如果发生这种情况,请关闭它并重新打开 UI Builder。

现在我们已经创建了我们的 UI,你可能已经注意到需要重复设置几个参数来使多个对象看起来相同,比如我们的生命条和标签。虽然这是完全可行的,但通过重用样式,我们可以极大地提高我们的工作流程,而 样式表 正是我们需要完成这一目标的精确功能,所以让我们来看看它们。

创建 UI 样式表

想象一下你正在设计各种游戏元素,如按钮和菜单,它们都具有相同的样式——相同的背景、字体、大小和边框。这在 UI 设计中是一个常见的场景,其中一致性是关键。当使用 uGUI 创建 UI 时,避免为每个元素重复配置的一种方法是为按钮创建 Prefab 并创建实例(以及必要时创建 Prefab 变体)。与 uGUI 不同,UI Toolkit 不使用 GameObject,因此我们无法依赖 Prefab 进行样式设置。但不用担心,我们有一个强大的替代方案:样式表

样式表是包含一系列针对我们 UI 元素样式预设的独立资源。我们可以定义一组样式(例如,背景、边框、字体、大小等),并将这些样式应用到不同 UI 元素中的多个元素上。这样,如果我们更改样式表资源中的样式,使用该样式的所有 UI 元素将以类似材料工作的方式发生类似变化。

在样式表中创建样式有几种方法。样式表的选择器系统有点像过滤器 - 你设置规则来决定哪些 UI 元素获得某些样式,就像网页设计中的 CSS 一样。类基本上是我们可以通过其名称应用到任何元素上的样式。例如,我们可以创建一个名为Button的类,并将其添加到我们想要应用该样式的 UI 中的每个按钮上。请注意,在这里,类的概念并不指编程类。类是一种对必须具有特定样式的 UI 元素进行标记的方式。

关于 USS 的高级技巧,请参阅此链接:docs.unity3d.com/2023.1/Documentation/Manual/UIE-USS.html

因此,在这种情况下,让我们为我们的 UI 中的所有标签创建一个类,这样只需更改样式,就可以修改所有标签的外观:

  1. UI Builder样式表面板中,点击添加(+)按钮,然后点击创建新 USS(Unity StyleSheet)。如果不起作用,请尝试重新启动 Unity;当前版本的 UI Toolkit 中存在一个可能导致此问题的错误:

计算机截图  描述由低置信度自动生成

图 16.20:创建 Unity 样式表

  1. 根据您的喜好命名 USS(例如,我的情况是GameUSS)并保存文件。

  2. 在我们的 UI 文档中选择一个标签元素,并查看检查器

  3. 检查器样式表面板中,在样式类列表输入字段中输入HUDText,但不要按Enter键。

  4. 点击将内联样式提取到新类按钮。这将把我们应用到标签(位置、大小、字体等)的所有样式修改保存到一个名为HUDText的新样式类中。您可以观察到它被添加到应用到元素的类列表中(那些在检查器样式表部分底部的标签):

计算机截图  描述由低置信度自动生成

图 16.21:将设置提取到样式类中

通过这些步骤,我们已经将需要应用到其他元素上的样式标签提取到一个名为HUDText的类中。这样,我们只需将HUDText类添加到 UI 中的其他元素,甚至可以将相同的 USS 资源添加到其他 UI 文档中(在样式表面板的+按钮上点击 | 添加现有 USS)以将此类添加到其中的元素。

此外,如果你再次选择标签,你会注意到之前加粗的属性现在又变回了正常;这是因为加粗的属性代表已更改的属性,我们已经提取了它们,所以默认值变成了样式类定义的值。幸运的是,并不是所有内容都被提取到新的 USS 类中;例如,Text字段仍然有我们特定的所需文本,因为你不太可能想要在其他对象中放入相同的文本。

图 16.22:文本属性加粗,表示它与默认值不同。另一方面,启用富文本没有加粗,意味着它遵循默认值和类的值

如果你提取类时遗漏了样式细节,不要担心。你可以轻松地在之后通过在StyleSheets部分选择类并做出编辑来调整它。然后,在列表中选择HUDText类。如果你看不到它,尝试展开GameUSS.uss部分。

一旦选中,你可以在Inspector面板中修改它,类似于我们更改 UI 元素的属性:

计算机程序屏幕截图  描述自动生成,置信度低

图 16.23:选择要修改的样式类

这样,我们就编辑了我们的HUDText类。如果其他元素应用了此类,它们也会应用这些更改。考虑另一种选择是首先创建类,在StyleSheets输入字段中键入名称,按Enter键,然后将它应用到 UI 元素上。这样,你将避免需要撤销不希望的变化,但如果首先创建了元素,那么有撤销选项会方便一些:

计算机屏幕截图  描述自动生成,置信度中等

图 16.24:从头创建样式类

现在我们有了我们的样式类,让我们通过以下步骤将其应用到其他元素上:

  1. 选择我们的 UI 中的另一个标签。

  2. HUDText样式从 UI Builder 窗口左上角的Stylesheet面板拖动到视口中的我们的元素上。你也可以选择将其拖动到Hierarchy元素上:

计算机屏幕截图  描述自动生成,置信度中等

图 16.25:将类应用于元素

  1. 选择Label,查看HUDText类是如何添加到InspectorStyleSheet部分的。

现在,请注意,即使元素现在应用了类,元素本身也有我们在上一步中做的文本更改,覆盖了类中的样式。你可以通过再次选择类(在样式表部分,位于UI Builder窗口的左上角)来轻松检查这一点,更改任何设置,如大小,然后看看不是所有元素都发生了变化。这显示了覆盖系统的工作原理;元素上的更改优先于它所应用的类中的更改。

如果你想移除这些覆盖,你可以简单地选择元素(不是类),在覆盖的属性上右键单击,并通过右键单击然后选择取消设置来撤销更改。在我们的标签情况下,我们可以取消整个文本部分的设置,以及可能取消绝对位置(因为期望的值已经包含在类中)。

图 16.26:撤销覆盖以使用应用于元素的类的默认值

因此,通过这些步骤,我们创建了一个新的样式表资产,并将其添加到 UI 文档中以便使用。我们在其中创建了一个新的样式类,从中提取了现有 UI 元素的更改,然后调整了我们想要保留的更改。最后,我们将该样式应用于另一个元素。通过这种方式,我们只是触及了 StyleSheets 真正力量的表面。我们可以开始做一些事情,比如从不同的样式表中组合不同的类,或者使用选择器动态设置样式,但这超出了本章的范围。

虽然 UI Toolkit 的文档仍在不断发展,但你可以通过深入研究 CSS 文献来提前掌握这些高级概念。CSS 的基本原理和最佳实践为 UI Toolkit 提供了有价值的见解。它不会完全相同,但基本思想和最佳实践仍然适用。

现在,UI 看起来几乎与第十五章中的样子完全相同,界面辉煌:设计用户友好的 UI,但它不会以相同的方式表现。如果你尝试更改视口的大小(通过在层次结构中选择GameHUD.uxml并更改宽度高度,就像我们在本章开头所做的那样),你会看到 UI 不会正确适应,所以让我们修复这个问题。

制作响应式 UI

在本节中,我们将学习如何使之前创建的 UI 适应不同的屏幕尺寸。我们将讨论以下概念:

  • 动态定位和大小

  • 动态缩放

  • 使用相对位置

让我们先讨论如何使我们的对象的位置和大小适应屏幕尺寸。

动态定位和大小

到目前为止,我们已经使用了 LeftTop 位置属性来指定元素相对于屏幕左上角的 xy 位置,然后使用 WidthHeight 来定义大小。虽然本质上,这已经足够定义一个对象的位置和大小,但在所有情况下都很有用,尤其是在我们需要适应不同屏幕大小的情况下。

例如,如果你需要将一个对象放置在屏幕的右上角,知道其大小为 100x100 像素,而屏幕大小为 1920x1080 像素,我们可以将 LeftRight 位置属性设置为 1820x980 像素,这样会有效,但仅适用于该特定分辨率。

那么,如果用户以 1280x720 像素运行游戏会发生什么?对象将超出屏幕。在 uGUI 中,我们使用了锚点来解决这个问题,但这里我们没有。幸运的是,我们有 RightBottom 来帮助。

LeftTop 属性类似,RightBottom 定义了从父元素边界的距离(如果没有父元素,则直接从整个屏幕)。目前,这两个都设置为 auto,意味着位置将由 LeftRight 独立驱动,但通过改变这些值可以发生有趣的事情,所以让我们通过以下方式使用它们,使我们的 ScoreBullet 标签粘附到屏幕的右上角:

  1. 将光标移至视口 UI 的底部,直到出现一个白色条。

  2. 拖动那个条来调整屏幕大小,并查看我们的 UI 如何适应(或不适应)不同的大小。

  3. 在侧边也做同样的操作,看看它如何适应不同的屏幕宽度:

图 16.27:UI 无法适应不同的屏幕大小

  1. 在视图中选择 Score 标签,并查看 Inspector

  2. Position 部分将 TopRight 的值设置为 30

  3. 通过点击每个属性右侧的 px 按钮并将 LeftBottom 的值设置为 auto

图 16.28:将位置属性的单元类型更改为自动模式

  1. 注意标签两侧的 RightTop 金色方块已填充,而 LeftBottom 方块是空心的。这意味着 LeftBottom 方块处于 auto 模式。如果需要,您也可以通过点击这些方块来切换 auto 模式:

图 16.29:切换元素位置属性自动模式

  1. 尝试再次更改 UI 容器的尺寸,就像我们在 步骤 12 中做的那样,看看我们的 Score 标签是如何始终对齐到右上角的。

  2. 重复 步骤 46Bullets 标签,这次将 Top 属性设置为 140

我们通过这些步骤所做的实际上是使对象的位置以像素为单位相对于 UI 的TopRight边或屏幕的右上角来表示。我们需要将其他边设置为自动模式,这样它们就不会参与位置计算。

现在,我们还可以以其他方式使用Position属性。正如你现在可能想象的那样,如果我们愿意,我们可以开始组合LeftRight以及TopBottom。在这种情况下,LeftTop将优先定义位置,但然后,RightBottom做什么呢?它们定义元素的大小。

例如,如果我们有一个元素,其LeftRight属性都设置为100px,并且我们在一个宽度为1920像素的屏幕上查看我们的 UI,那么我们元素的最终宽度将是1720(从Left减去100,从Right减去100)。这样,Position属性表示我们的元素边框与屏幕边框(或父元素)的距离。

让我们通过以下步骤来观察其效果,使底部健康条适应屏幕宽度,同时通过以下方式保持其相对于屏幕底部的位置:

  1. Hierarchy中选择底部健康条的父元素。不要在视图中选择它,因为你只会选择其填充或边框。

  2. LeftRightBottom设置为50px

  3. Top设置为自动(点击右侧的px按钮并选择自动)。

  4. Size部分,也将Width设置为自动

  5. Height设置为35px

图 16.30:使玩家的基础生命条适应屏幕宽度

  1. 改变 UI 的大小以查看其如何适应。

通过这些步骤,我们定义了条与屏幕边框的距离为50像素,以便它适应任何屏幕宽度,同时保持与边框和高度的固定距离。我们基本上实现了与 uGUI 中的分割锚点相同的行为!请注意,我们需要将Size Width属性设置为自动,以便LeftRight属性驱动位置;如果不这样做,Width属性将优先,Right将没有任何效果。我邀请您尝试其他px/auto的组合。

我们在这里可以做的最后一个技巧是,在健康条边框的LeftTopRightBottom Position属性中使用负值,使边框略大于容器并覆盖填充边框。在这种情况下,将LeftTopRightBottom设置为-15px,并记住将Size WidthHeight属性设置为自动。你可能想稍微降低条容器的宽度(不是边框),因为现在它将因为这种变化而看起来更厚:

图 16.31:使用负的 Position 属性来覆盖填充

除了px(像素)或auto模式之外,还有一种百分比(%)模式,它允许我们以相对于屏幕(或如果存在则相对于父元素)的大小表示值。例如,如果我们设置顶部底部25%,这意味着我们的元素将在垂直居中,大小为屏幕高度的 50%(请记住在此处将高度设置为auto)。如果我们设置顶部25%底部Auto高度50%,我们也可以达到相同的结果;正如你所看到的,我们可以巧妙地组合这些值。

在我们的案例中,我们将使用百分比值在我们的生命条填充中,这样我们就可以用百分比来表示它们的大小。我们需要这样做,以便在代码中稍后可以指定条的宽度为玩家生命值的百分比(例如,一个有25生命值和最大100点的玩家有25%的生命)。

现在,虽然我们通过使用属性解决了适应屏幕尺寸的问题,但我们还没有解决元素的动态尺寸问题。这次,我们指的是具有不同DPI每英寸点数)的屏幕,因此让我们讨论如何使用面板设置资产来实现这一点。

动态缩放

我们使用1920x1080作为 UI 基本分辨率来定位和调整我们的元素,以便在该分辨率下看起来很漂亮。在调整 UI 时,我们了解了元素如何适应其位置,你可能已经观察到元素大小发生了显著变化,看起来更大或更小。这是动态 UI 缩放中需要考虑的重要方面。

虽然有一个基本的参考分辨率对于设计我们的 UI 是有益的,但我们应考虑不同分辨率下元素的大小,尤其是在高 DPI 的屏幕上。有时,你可能会有分辨率更高但物理尺寸相同的厘米数屏幕。

这意味着高分辨率下的像素更小;因此,它们具有更高的 DPI,如果未正确缩放,元素可能会显得更小。

在 UI 设计中缩放确保你的界面在任何屏幕尺寸上看起来都很棒。以前,在传统的 Unity UI 中,我们依赖于 Canvas Scaler 来实现这一点。现在,在 UI Toolkit 中,我们使用类似的方法,但通过面板设置资产来实现。让我们看看这是如何配置的,以在不同分辨率之间保持一致的 UI 元素:

  1. 项目面板中查找面板设置资产并选择它。另一个选项是选择主编辑器层次结构中的UI Document GameObject,并点击面板设置属性中引用的资产:

图 16.32:UI 文档组件中引用的面板设置

  1. 缩放模式设置为随屏幕尺寸缩放

  2. 屏幕匹配模式设置为匹配 宽度或高度

  3. 参考分辨率 X值设置为1920,将Y值设置为1080

  4. 匹配滑块完全向右移动,直到标记为高度的末端:

图 16.33:设置我们的 UI 缩放

  1. 观察如何改变 Unity 编辑器的游戏面板的高度,将使 UI 相应地调整元素大小(即,改变整个 Unity 编辑器窗口的高度)。

我们所做的这些更改首先是将参考分辨率设置为我们的 UI 设计的分辨率——在我们的案例中,是1920x1080。然后,我们将屏幕匹配模式设置为允许我们根据一边、宽度高度或两者的组合来缩放我们的元素,如果我们更喜欢的话。我们专注于高度进行缩放,因为 PC 通常有更宽的屏幕。这个选择确保了我们的 UI 元素在各种屏幕高度上保持视觉一致性。这意味着在不同的屏幕宽度上,元素看起来大小相同,但在不同的高度上,元素会更大或更小。

使用这些设置,我们可以做一些数学计算来理解这些值。如果我们的屏幕与参考分辨率(1920x1080)相同,元素大小将与我们在像素大小中指定的元素大小相同,所以对于我们的玩家头像,它将是150x150像素。记住,物理大小以厘米为单位取决于屏幕的 DPI。

通过对如何在 4K 屏幕上缩放 UI 元素的理解,让我们将这一知识付诸实践。现在,我们将调整我们的 UI 设置以有效地适应这种高分辨率显示器。按照以下步骤操作,看看我们的 UI 元素如何响应和适应 4K 分辨率的设置。

现在,假设我们有一个 4K 屏幕,意味着分辨率为3840x2160。因为我们指定我们的 UI 通过高度匹配,我们可以确定我们的元素大小将加倍,因为我们的屏幕高度是参考分辨率的两倍(2160除以1080)。我们的玩家头像,大小为300x300像素,在 4K 屏幕上将保持其物理大小;这是通过屏幕的双倍大小和双倍像素密度实现的。最后,考虑一个超宽标准分辨率2560×1080(是的,非常宽的屏幕),在这种情况下,元素大小将保持不变,因为唯一的变化是宽度;唯一的不同是,由于屏幕尺寸,元素将因屏幕尺寸而具有更多的水平间隔。我知道这些计算可能很复杂,但请继续实验面板设置游戏视图大小中的值,以更好地理解它们。

太好了,现在我们真的有了相同的 HUD。我们可以开始将迄今为止看到的概念应用到选项菜单中,但让我们抓住机会以不同的方式来做,使用相对位置,这是一种创建元素流动的方法,其中元素的位置相互依赖。

使用相对位置

在我们游戏的 HUD 中,每个元素都需要自己的位置大小,并且不同元素的位置可以调整大小和重新定位,而不会影响其他元素。我们可能会观察到玩家生命条和角色的例子,但在这个情况下变化将是微不足道的。还有其他情况,这种情况并不那么简单,例如在需要垂直或水平调整的元素列表(例如,在多人游戏中加入的匹配列表)中,这时相对位置就帮到了我们。

相对位置允许我们将元素的位置相对于彼此进行调整;从某种意义上说,一个元素的位置将取决于前一个元素的位置,前一个元素的位置又取决于它的前一个元素,以此类推,形成一个链或流程。这在 uGUI 的垂直和水平布局中是类似的。在我们的案例中,我们将使用这些方法使暂停标签以及选项退出按钮在它们的父元素中垂直居中。

让我们开始创建菜单,按照以下步骤操作:

  1. 创建一个新的 UI Document(在Project View | UI Toolkit | UI Document后点击+按钮),并将其命名为OptionsMenu。我们可以继续在之前的 UI Document 上工作,但让我们将这些 UI 组件分开,以便于激活和停用以及一般的资产组织。

  2. 双击资产以将其设置为当前由UI Builder编辑的 UI。

  3. 选择根对象(OptionsMenu.uxmlHierarchy中)并设置Inspector中的WidthHeight属性为1920x1080像素。

  4. 创建一个新的具有UI Document组件的 GameObject(GameObject | UI Toolkit | UI Document),并将该对象的资产拖动以渲染它(就像我们在本章早期创建的 HUD 一样)。

  5. 双击UI Document资产以打开UI Builder窗口进行编辑,并在该窗口中,将一个新的VisualElement拖动到Hierarchy或视图中,并将其命名为Container(在UI BuilderInspector中的Name属性)。

  6. 位置属性设置为0px

  7. 位置设置为绝对

  8. 大小部分将宽度高度设置为自动。这将使容器适应整个屏幕。

  9. 将一个新的VisualElement拖动到容器中作为子元素,并将其命名为Background

  10. 这次将位置设置为相对

  11. 大小宽度高度设置为500px

  12. Background对象的背景图像设置为使用上一章中使用的相同的背景精灵。

  13. 选择Container父对象(不是Background)。

  14. Inspector中,将Align部分的Align Items属性设置为center,即第三个按钮。如果将鼠标悬停在图标上,它们将在工具提示中显示其名称。

  15. Justify Content设置为居中(第二个按钮):

图 16.34:准备 UI 背景以容纳内部元素

  1. 使用侧面的白色条来更改 UI 的大小,以查看背景始终居中。

只需放置一个元素,这就是观察相对定位在实际环境中如何工作的绝佳机会。首先,我们创建了一个空对象,它将始终适应屏幕大小,使我们能够使子元素依赖于全屏大小。然后,我们创建了一个具有固定大小但具有相对位置的图像元素,这意味着其位置将由父容器计算。最后,我们告诉容器使其子对象与其水平和垂直中心对齐,因此背景立即居中,无论屏幕大小如何。当使用绝对位置时,Align 属性不起作用,因此这是相对定位的第一个好处。

但相对定位在多个元素中变得更加强大,因此让我们将 Label 和按钮添加到我们的 Background 元素中,通过以下方式进一步探索这个概念:

  1. UI Builder 左下角的 Library 面板中,将一个 Label 元素和两个 Button 元素拖放到 Hierarchy 中的 Background 内。请注意,有时即使你将新元素拖放到所需对象内,它也不会成为其子元素。这次请只拖动在 Hierarchy 中创建的元素:

图 16.35:在菜单背景中添加元素

  1. 观察默认情况下,元素如何由于相对位置的默认设置而垂直对齐,一个叠在另一个上面:

按钮的特写  描述由低置信度自动生成

图 16.36:自动相对垂直定位

  1. 选择 Background 元素,并将 Justify Content 设置为 space-around(第五个按钮)。这将沿背景分布元素。

  2. Align Items 设置为居中(第三个选项)以水平居中元素:计算机屏幕截图  描述由低置信度自动生成

    图 16.37:自动相对垂直定位

    对于 Justify Content 有一个类似的模式,称为 space-between(Justify Content 中的第四个按钮),它也会沿垂直轴分布元素,但不会在第一个元素顶部或最后一个元素底部留下空间。此外,Align Items 有一个名为 Stretch(第五个选项)的选项,与 center 类似,它不仅会在水平方向上居中元素,还会拉伸它们而不是尊重每个元素的宽度。我建议尝试不同的对齐模式,以发现所有机会。

  3. LabelTextFontSize 属性设置为适合的任何值。在我的情况下,我使用了导入的字体和 60px 的大小。记住也要将 Text 设置为 Pause

  4. 按钮****背景图像设置为与上一章中使用的按钮相同的图像。

  5. 背景部分的颜色属性设置为没有 alpha 的颜色。您可以通过单击颜色矩形并将颜色选择器中的A通道减少到0来实现这一点。这种颜色的想法是作为我们图像的背景,但我们不需要它,所以我们使其完全透明。

  6. 将按钮文本****字体大小颜色设置为适合您的任何内容。在我的情况下,我使用50和灰色。

  7. 边距和填充部分,将填充设置为在文本和按钮边框之间留出一些空间。在我的情况下,30px就做到了这一点:

图 16.38:向按钮内容添加内部填充(在这种情况下是文本)

  1. 此外,将背景顶部底部****填充设置为在窗口边框和其元素之间留出一些空间。在我的情况下,每个都是40px

如您所见,我们更改了不同的设置来动态设置元素的大小,例如字体大小和填充,以及相对系统,同时对齐设置自动确定元素的位置。我们可以通过在层级中拖动元素来重新排列元素顺序,它们将自动适应。我们也可以使用大小属性来设置元素的大小,并且如果需要,可以使用位置属性应用一些偏移,但我鼓励您自己查看这些属性在相对模式下的行为。

我还想让您探索的最后一个设置是Flex部分的方向属性,正如您所想象的,这将确定元素将遵循的定位方向,垂直从上到下或从下到上,以及水平从左到右或从右到左。例如,您可以将方向设置为使用模式(第三个按钮)从左到右分配元素,如果您愿意,可以使背景更宽以创建一个水平选项菜单。

图 16.39:更改元素为垂直方向

作为旁注,您可能已经注意到背景和按钮的图像看起来比上一章创建的选项菜单要大。这是因为我们在纹理资产上更改的每单位像素设置,用于控制纹理的缩放,在 UI 工具包中不会生效;您需要手动在任何图像编辑器中更改纹理文件大小,以给它适当的大小。这里的最佳实践是始终创建大小适合我们最大支持分辨率的图像。通常,在 PC 上这是1920x1080,但请注意,4K 分辨率每天都在变得越来越流行。

概述

在本章中,我们介绍了 UI 工具包的关键概念以及如何创建 UI 文档和样式表。关于 UI 文档,我们学习了如何创建不同的元素,如图片、文本和按钮,以及如何使用不同的方法(如绝对定位和相对定位以及像素或百分比单位)来定位和调整它们的大小。此外,我们还看到了如何使用不同的 Position 属性组合使 UI 适应不同的尺寸。最后,我们学习了如何使用 USS 样式表在不同元素之间共享样式,以便轻松管理整个 UI 皮肤。

实质上,我们再次学习了如何使用不同的系统来制作 UI。再次提醒,这个系统仍然处于实验阶段,不建议用于实际生产项目。我们使用所有这些概念来重新创建在 第十五章 中创建的相同 UI,即 界面辉煌:设计用户友好的 UI

在下一章中,我们将看到如何为我们的游戏添加动画,使我们的角色移动。我们还将看到如何创建过场场景和动态摄像机。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接以加入社区:

packt.link/unitydev

第十七章:动画现实:使用 Animator、Cinemachine 和 Timeline 创建动画

有时,我们需要以预定的方式移动对象,例如使用过场动画,或特定的角色动画,如跳跃、奔跑等。在本章中,我们将介绍几个 Unity 动画系统,以创建我们可以通过脚本获得的所有可能的物体运动。

在本章中,我们将探讨以下动画概念:

  • 使用 Animator 进行蒙皮动画

  • 编写动画脚本

  • 使用 Cinemachine 创建动态摄像机

  • 使用时间轴创建过场动画

到本章结束时,你将能够创建过场动画来讲述你游戏的历史或突出你关卡中的特定区域,以及创建能够准确呈现游戏场景的动态摄像机,无论情况如何。

使用 Animator 进行蒙皮动画

到目前为止,我们使用的是所谓的静态网格,它们是固体三维模型,不应该以任何方式弯曲或动画化(除了像汽车门那样单独移动)。

我们还有一种另一种类型的网格,称为蒙皮网格,这种网格可以根据骨骼变形,因此可以模拟人体肌肉运动。我们将探讨如何将动画化的类人角色集成到我们的项目中,以创建敌人和玩家的动作。

在本节中,我们将探讨以下骨骼网格概念:

  • 理解蒙皮

  • 导入蒙皮网格

  • 使用 Animator 控制器进行集成

  • 使用头像蒙版

现在我们已经介绍了蒙皮网格,让我们更深入地探讨蒙皮的功能及其在角色动画中的关键作用。然后,我们将把动画网格带入我们的项目,最终对它们应用动画。让我们先讨论如何将骨骼动画带入我们的项目。

理解蒙皮

为了获得一个动画网格,我们需要四个部分,首先是将要动画化的网格,其创建方式与任何其他网格相同。然后,我们需要骨骼,它是一组与所需网格拓扑相匹配的骨骼,例如手臂、手指、脚等。在图 17.1中,你可以看到一个与我们的目标网格对齐的骨骼示例:

图 17.1:一个与默认姿势匹配的骨骼忍者网格

一旦艺术家创建了模型及其骨骼,下一步就是进行蒙皮,即把模型的每个顶点关联到一到多个骨骼上。这样,当你移动一个骨骼时,相关的顶点也会随之移动。

图 17.2中,你可以看到网格三角形的着色是根据骨骼的颜色进行的,这以可视化的方式影响了骨骼。你会注意到颜色之间的混合,这意味着这些顶点受到不同骨骼的不同影响,以便使接近关节的顶点弯曲得更好。此外,图 17.2还展示了用于二维游戏的二维网格的示例,但概念是相同的:

图片

图 17.2:网格蒙皮权重以颜色形式直观表示

最后,你需要的是实际的动画,它将简单地由网格骨骼的不同姿势的混合组成。艺术家将在动画中创建关键帧,确定模型在不同时刻需要具有的姿势,然后动画系统将简单地在这之间进行插值。基本上,艺术家将动画化骨骼,蒙皮系统将此动画应用于整个网格。

为了获得这四个部分,我们需要获取包含它们的适当资产。在这种情况下,通常的格式是FilmboxFBX),我们之前用它来导入 3D 模型。此格式可以包含我们需要的每一部分——模型、带有蒙皮的骨骼和动画,但通常这些部分将分成几个文件以供重新利用。

想象一个城市模拟器游戏,其中我们有几个具有不同外观的市民网格,并且所有这些都必须进行动画处理。如果我们为每个市民有一个包含网格、蒙皮和动画的 FBX 文件,那么每个模型都将有自己的动画,或者至少是相同的克隆,重复它们。当我们需要更改该动画时,我们需要更新所有网格市民,这是一个耗时的过程。

而不是这样,我们可以为每个市民有一个包含网格、基于该网格的正确蒙皮的骨骼的 FBX 文件,以及每个动画一个单独的 FBX 文件,包含所有市民都具有的正确动画的相同骨骼,但没有网格。这将使我们能够混合和匹配市民 FBX 文件与动画 FBX 文件。你可能想知道为什么模型 FBX 和动画 FBX 都必须有网格。这是因为它们需要匹配才能使两个文件兼容。在图 17.3中,你可以看到文件应该如何看起来:

图片

图 17.3:我们将在项目中使用的动画和模型 FBX 文件

此外,还有一个概念叫做重定向。正如我们之前所说,为了混合模型和动画文件,我们需要它们具有相同的骨骼结构,这意味着相同的骨骼数量、层次结构和名称。

有时,这可能不可行,尤其是在我们将我们艺术家创建的定制模型与您可以使用动作捕捉技术记录或仅通过购买mocap(动作捕捉)库(使用特定动作捕捉硬件在真实人类上捕获的一系列动画)的外部动画文件混合时。在这种情况下,您很可能会在 mocap 库和您的角色模型之间遇到不同的骨骼结构,因此这就是重定向发挥作用的地方。这项技术允许 Unity 在两个不同的人形骨骼结构之间创建通用的映射,使它们兼容。在下一节导入骨骼动画中,我们将看到如何启用此功能。

现在我们已经了解了带皮肤网格的基本原理,让我们看看我们如何获取带有骨骼和动画的模型资产。

导入骨骼动画

我们可以在3D | Characters | Humanoids部分在资产商店中搜索来下载角色模型。您还可以使用外部网站,例如名为 Mixamo 的网站来下载它们。请注意,有时您可能需要下载几个包,因为一些包只包含带皮肤的模型,而另一些只包含动画。幸运的是,我们在第四章,无缝集成:导入和整合资产中下载的模型已经包含了带皮肤的网格和动画。

在我的包内容中,我可以在Animations文件夹中找到动画的 FBX 文件,以及我在Mesh文件夹中名为Polyart_Mesh的模型的 FBX 文件。请记住,有时它们可能不会像这样分开,如果存在任何动画,动画可能位于与模型相同的 FBX 文件中。现在我们已经有了所需的文件,让我们讨论如何正确配置它们。

让我们从选择模型文件并检查绑定选项卡开始。在此选项卡中,您将找到一个名为动画类型的设置,如图17.4所示:

计算机屏幕截图 描述自动生成,置信度低

图 17.4:绑定属性

此属性包含以下选项:

  • None:用于非动画模型的模式;您游戏中的每个静态网格都将使用此模式。

  • Legacy:用于旧 Unity 项目和模型的模式;不要在新项目中使用此模式。

  • Generic:一种可用于所有类型模型的新动画系统,但通常用于非人类模型,如马、章鱼等。如果您使用此模式,模型和动画 FBX 文件必须具有完全相同的骨骼名称和结构,从而降低从外部来源组合动画的可能性。

  • 人类模型:为人类模型设计的新动画系统。它启用诸如重定向和逆运动学IK)等功能。这些功能允许您使用与动画骨骼不同的骨骼结构的模型。为此,Unity 使用此模式将模型和动画的骨骼结构转换为标准的人类骨骼结构,使它们相互兼容。这种结构称为Avatar。请注意,有时自动映射可能会失败,您可能需要手动纠正;因此,如果您的通用模型包含您所需的一切,我建议您如果 FBX 的默认配置是通用,则坚持使用通用

在我的情况下,我包中的 FBX 文件的模式设置为人类模型,这是好的,但请记住,只有当绝对必要时才切换到其他模式(例如,如果您需要组合不同的模型和动画)。现在我们已经讨论了绑定设置,让我们谈谈动画设置。

要这样做,选择任何动画 FBX 文件,并在检查器窗口中查找动画选项卡。您将找到几个设置,例如,如果文件包含动画(不是模型文件),则必须标记导入动画复选框,以及剪辑列表,其中您将找到文件中的所有动画。在以下截图中,您可以看到我们的动画文件之一的剪辑列表:

包含文本、截图、数字和字体的图片,自动生成描述

图 17.5:动画设置中的剪辑列表

带有动画的 FBX 文件通常包含一个单独的大动画轨道,该轨道可以包含一个或多个动画。无论如何,默认情况下,Unity 将根据该轨道创建一个单独的动画,但如果该轨道包含多个动画,您将需要手动拆分它们。在我们的案例中,我们的 FBX 包含一个单独的动画,但为了了解如何在其他情况下拆分它,请按照以下步骤操作:

  1. 剪辑列表中,选择您想要重新创建的任何动画;在我的情况下,我将选择Run_guard_AR

  2. 查看动画时间线下面的开始结束值,并记住它们;我们将使用它们来重新创建此剪辑:

计算机截图,自动生成描述,置信度低

图 17.6:剪辑设置

  1. 使用+按钮创建一个新的剪辑并选择它。

  2. 使用当前显示为类似Take 001的输入字段将其重命名为与原始名称类似的内容。在我的情况下,我将将其命名为Run

  3. 使用我们在步骤 2中需要记住的值设置结束开始属性。在我的情况下,我将结束设置为20,将开始设置为0。这些信息通常来自制作动画的艺术家,但您可以尝试最适合的数字,或者简单地拖动时间轴顶部的蓝色标记到这些属性上。

  4. 如果动画需要循环播放,请勾选循环时间复选框以确保这一点。这将使动画不断重复,这在大多数动画如行走跑步中是必需的。如果不勾选,动画将只播放一次,不会重复:

计算机屏幕截图  描述自动生成,置信度中等

图 17.7:循环动画

  1. 通过点击检查器窗口底部为您的动画命名的栏(在我的例子中是),然后点击播放按钮来预览片段。在某些情况下,您可以看到默认的 Unity 模型,但您可以通过将模型文件拖动到预览窗口中来查看自己的模型,因为检查我们的模型是否正确配置非常重要。如果动画无法播放,您需要检查动画类型设置是否与动画文件匹配:

图 17.8:动画预览

  1. 通过点击左侧的箭头打开动画资产(FBX),并检查子资产。您会看到与您的动画具有相同名称的资产:

计算机屏幕截图  描述自动生成,置信度中等

图 17.9:生成的动画片段

  1. 请记住,除了初始帧结束帧循环时间之外,还有很多其他设置。我下载的角色需要其他设置,如根变换 旋转根变换位置遮罩才能使其工作,不同角色包之间的差异可能很大。如果您正在重新创建现有的动画,请考虑复制所有设置或仅使用默认设置。这些提到的设置超出了本书的范围,但您始终可以在 Unity 文档中查阅它们,网址为 docs.unity3d.com/Manual/class-AnimationClip.html

现在我们已经介绍了基本配置,让我们学习如何集成动画。

使用动画控制器进行集成

当我们将动画添加到角色时,我们需要考虑动画的流程,这意味着思考哪些动画必须播放,每个动画何时必须处于活动状态,以及动画之间的过渡应该如何发生。在之前的 Unity 版本中,您需要手动编写代码来实现这一点,生成复杂的 C# 代码脚本以处理复杂场景,但现在,我们有动画控制器

动画控制器是基于状态机的资产,我们可以使用名为动画器的可视编辑器来绘制动画之间的转换逻辑。想法是每个动画都是一个单独的状态,我们的模型将拥有几个这样的状态。一次只能有一个状态处于活动状态,因此我们需要创建转换来改变它们,这将具有必须满足的条件才能触发转换过程。条件是对要动画化的角色的数据进行比较,例如其速度、是否在射击或蹲下等。

因此,基本上,动画控制器或状态机是一组具有转换规则的动画,它将决定哪个动画应该是活动的。让我们通过以下步骤开始创建一个简单的动画控制器:

  1. 项目视图下点击+按钮,点击动画控制器,并将其命名为Player。请记住将你的资产定位在文件夹中以进行适当的组织;我将我的命名为Animations

  2. 双击资产以打开动画器窗口。不要将此窗口与动画窗口混淆;动画窗口用于创建新动画,但到目前为止,我们将坚持使用下载的动画。动画器窗口负责以可视方式创建和编辑动画状态机,类似于视觉脚本。

  3. 在你的角色包的动画文件夹中搜索你角色的空闲动画片段,并将其拖入动画器窗口。在我的例子中,它被命名为Idle_guard_ar。请记住拖动子资产,而不是整个文件。这将在动画控制器中创建一个代表动画的框,该框将连接到控制器的入口点,表示该动画将是默认的,因为它是我们首先拖动的。如果你没有空闲动画,我鼓励你从资产商店下载一个,也许可以在其他角色的包中搜索。我们需要至少一个空闲和一个行走/跑步动画片段:

图 17.10:将动画片段从 FBX 资产拖入动画控制器

  1. 以相同的方式拖动跑步动画,在我的例子中是Run_guard_AR

  2. 动画器窗口中右键单击空闲动画框,选择创建转换,然后左键单击跑步动画。这将创建空闲跑步之间的转换。

  3. 以相同的方式从跑步创建到空闲的另一个转换:

计算机屏幕截图  描述由低置信度自动生成

图 17.11:两个动画之间的转换

转换必须具有条件,以防止动画不断交换,但为了创建条件,我们需要数据来进行比较。我们将向我们的控制器添加属性,这些属性将代表转换使用的数据。稍后在本章的脚本动画部分,我们将设置这些数据以匹配我们对象当前的状态。但到目前为止,让我们创建数据并测试控制器对不同值的反应。为了根据属性创建条件,请执行以下操作:

  1. 点击动画器窗口左上角的参数选项卡。如果您看不到它,点击看起来像被横线穿过眼睛的按钮以显示选项卡。图标将变为未交叉的眼睛。

  2. 点击+按钮并选择浮点数来创建一个将代表我们角色速度的数字,命名为Velocity。如果您错过了重命名部分,只需左键单击变量并重命名它:

计算机截图  描述由中等置信度自动生成

图 17.12:带有浮点速度属性的参数选项卡

  1. 点击怠速到运行转换(中间带有箭头的白色线条)并查看检查器窗口中的条件属性。

  2. 点击列表底部的+按钮,这将创建一个将控制转换的条件。默认设置将取我们动画器的第一个参数(在这种情况下,它是速度),并将默认比较器,在这种情况下,大于,设置为0的值。这告诉我们,如果怠速是当前动画且玩家的速度大于0,则转换将从怠速运行执行。我建议您设置一个略高的值,例如0.01,以防止任何浮点舍入错误(一个常见的 CPU 问题)。此外,请记住,速度的实际值需要通过脚本手动设置,我们将在本章的脚本动画部分进行设置:

计算机截图  描述由低置信度自动生成

图 17.13:检查速度是否大于 0.01 的条件

  1. 运行到怠速的转换也做同样的操作,但这次,将大于改为小于,并将值设置为0.01

计算机截图  描述由中等置信度自动生成

图 17.14:检查值是否小于 0.01 的条件

将控制器应用于您的角色

现在我们已经设置了第一个 Animator Controller,是时候将其应用到对象上了。为了做到这一点,我们需要一系列组件。首先,当我们有一个动画角色时,而不是使用常规的 Mesh Renderer,我们使用皮肤网格渲染器。如果你选择你的玩家或敌人角色并查看它们的子 GameObject,你将看到它们中的一个或多个有皮肤网格渲染器:

图 17.15:一个皮肤网格渲染器组件

此组件将负责将骨骼的运动应用到网格上。如果你搜索模型的子项,你会找到一些骨骼;你可以尝试旋转、移动和缩放它们以查看效果,如以下屏幕截图所示。

请记住,如果你的骨骼层次结构与我的不同,如果你从 Asset Store 下载了另一个包:

图 17.16:旋转颈骨

我们还需要另一个组件,即Animator,它将自动添加到根 GameObject 的皮肤网格中。如果动画 FBX 文件配置正确,如我们之前提到的,该组件将负责应用我们在 Animator Controller 中创建的状态机。为了应用 Animator Controller,请执行以下操作:

  1. 层次中选择玩家,并在根 GameObject 中定位Animator组件。

  2. 点击控制器属性右侧的圆圈,并选择我们之前创建的Player控制器。你也可以直接从项目窗口拖动它。

  3. 确保将Avatar属性设置为角色 FBX 模型内部的头像(在我们的示例项目中,Polyart_Mesh是 FBX 模型);这将告诉动画师我们将使用那个骨骼。你可以通过以下屏幕截图中显示的人形图标来识别头像资产。通常,当你将 FBX 模型拖动到场景中时,此属性会自动正确设置:

图 17.17:使用玩家控制器和机器人头像的 Animator

  1. 在不停止游戏的情况下,通过双击它并选择层次面板中的角色,再次打开 Animator Controller 资产。通过这样做,你应该能看到该角色正在播放的动画的当前状态,使用条形图表示动画的当前部分:

计算机屏幕截图  描述由低置信度自动生成

图 17.18:选择对象时 Play 模式下的 Animator Controller,显示当前动画及其进度

  1. 使用Animator窗口,将速度的值更改为1.0并查看过渡将如何执行。如果你愿意,可以禁用WaveSpawners来测试这个,因为它们可能会在我们安全这样做之前杀死玩家:

图 17.19:设置控制器的速度以触发过渡

  1. 根据如何设置运行动画,你的角色可能会开始移动而不是在原地执行动画。这是由根运动引起的,这是一个基于动画运动移动角色的功能。有时,这很有用,但由于我们将完全通过脚本移动我们的角色,我们希望该功能被关闭。你可以通过在角色对象的动画器组件中取消选中应用根运动复选框来实现,如图图 17.17所示。

  2. 你也会注意到在更改速度值和动画过渡开始之间有一个延迟。这是因为默认情况下,Unity 会在执行过渡之前等待原始动画结束,但在这个场景中,我们不想这样。我们需要过渡立即开始。为了做到这一点,选择每个过渡的控制器,并在检查器窗口中取消选中具有退出时间复选框。当此属性被选中时,过渡执行的一个隐藏条件是等待动画结束。但取消选中后,过渡可以在动画的任何时刻执行,这正是我们想要的,因为我们不希望在玩家空闲和跑步之间有任何延迟:

计算机屏幕截图  描述自动生成

图 17.20:取消选中“具有退出时间”复选框以立即执行过渡

你可以将其他动画拖动到控制器中,并创建复杂的动画逻辑,例如添加跳跃、跌倒或蹲下动画。我邀请你尝试其他参数类型,例如布尔值,它使用复选框而不是数字。此外,随着你游戏的进一步开发,你的控制器可以处理的动画数量将会增加。为了管理这一点,有一些其他值得研究的功能,例如混合树和子状态机,但这超出了本书的范围。

在本节中,我们学习了如何通过 Animator Controllers 将动画剪辑集成到我们的角色中。我们添加了所有必要的动画,并创建了它们之间的必要过渡,以应对游戏情况,如角色速度变化。

现在我们已经集成了空闲和跑步动画,让我们集成射击动画,这需要我们使用Avatar Masks

使用 Avatar Masks

Unity 提供了一个名为 Avatar Masks 的强大功能,它允许对角色部分进行选择性动画。这个功能在即将探索的复杂场景中特别有用。

起初,这个案例看起来就像拖动射击动画并使用Shooting布尔参数作为条件来创建转换那样简单。然而,考虑到我们可以在行走和奔跑时射击,这导致有两个射击动画:行走射击空闲射击。如果您遵循这个逻辑,您可以考虑在跌倒、跳跃等情况下射击,这将导致更多的动画组合。想象一下为不同武器拥有不同的射击动画!幸运的是,我们有一个更好的解决方案:使用 Avatar Masks 结合多个动画的方法。在 Unity 中,Avatar Masks是允许选择性地对角色身体特定部分进行动画的工具,使得在 Animator Controller 中可以精确控制复杂的动画。

在 Animator Controller 中创建的动画状态机被称为,一个 Animator Controller 可以有多个层。这意味着我们可以在 Animator Controller 中拥有多个状态机。使用这种方法有几个原因,但常见的一个是将层与 Avatar Masks 结合,这是一个允许我们使特定的 Animator Controller 层或状态机影响某些骨骼的资产,因此我们可以为身体的不同部分设置不同的状态机。

我们可以用这个来解决之前讨论的射击场景,将我们的玩家动画逻辑分成两部分:身体的上半部分和下半部分。想法是下半部分将在空闲和奔跑动画之间切换,而上半部分可以在空闲、奔跑和射击之间切换。这允许我们拥有下半部分在奔跑而上半部分在射击,或者下半部分空闲而上半部分也空闲,或者任何我们可以想象到的组合。

让我们从以下步骤开始创建第二个层:

  1. 如果您还没有,可以从互联网或 Asset Store 下载射击动画。在我们的例子中,我们已经有几个射击动画,我们将选择名为Idle_Shoot_ar的动画。

  2. 在 Animator Controller 中,在基础层上单击一次,为了组织目的将其重命名为LowerBody。如果您看不到层列表,请点击Animator窗口右上角的按钮:

计算机屏幕截图  描述自动生成,置信度中等

图 17.21:重命名基础层

  1. 使用+按钮向控制器添加第二个层,并将其重命名为UpperBody。这个层将处理上半身的动画。

  2. 选择该层,并向其添加IdleRunShoot动画,通过转换连接状态。请记住在每个转换中取消选中Has Exit Time。这样,上半身就有与下半身相同的动画,还有射击动画。

计算机屏幕截图  描述自动生成,置信度中等

图 17.22:UpperBody 状态机

  1. 使用之前在 IdleRun 之间使用的相同转换逻辑,使用 Velocity 作为条件参数,如之前所述。

  2. 对于射击转换,创建一个名为 Shooting 的布尔参数:

包含文本、截图、字体、数字的图片,自动生成描述

图 17.23:射击布尔值

  1. Shooting 布尔值为 true 时,执行两种射击转换(IdleShootRunShoot)。

  2. Shooting 布尔值为 falseVelocity 小于 0.01 时,从 Shoot 返回到 Idle,当 ShootingtrueVelocity 大于 0.01 时,从 Shoot 返回到 Run

计算机截图,自动生成描述,置信度低

图 17.24:顶部为射击到空闲状态的转换,中间为射击到运行状态的转换,底部为空闲到射击和运行到射击的两种转换

现在我们已经创建了层,让我们将这些 Avatar Masks 应用到它们上:

  1. 使用 Project View 中的 + 按钮创建一个 Avatar Mask,并将其命名为 UpperBodyMask

  2. Inspector 中选择 UpperBodyMask 资产,并点击左侧显示 Humanoid 的箭头以展开此部分。

  3. Inspector 中点击显示的身体下半部分,直到它们变成红色:

图 17.25:UpperBodyMask 资产配置

  1. 在 Animator Controller 中,选择 UpperBody 层,并点击其右侧的齿轮图标以显示一些选项。

  2. 点击 Mask 属性右侧的圆圈,并在出现的窗口中选择 UpperBodyMask 资产。

  3. 再次点击 UpperBody 层的轮子,并将其 Weight 设置为 1。由于两个层影响身体的不同部分,它们具有相同的优先级。在两个层影响相同骨骼的情况下,权重用于计算哪个层有更大的影响:

图 17.26:设置层的权重和遮罩

  1. 再次点击轮子,观察 Blending 参数是否设置为 Override,这意味着此层影响的骨骼(由 Avatar Mask 驱动)将覆盖基础层(在这种情况下为 LowerBody)的任何动画。这就是此层如何接管身体上半部分的原因。

  2. Play 模式下测试此操作,同时更改参数的值。例如,尝试勾选 Shooting,然后将 Velocity 设置为 1,然后设置为 0,最后取消勾选 Shooting,看看转换是如何执行的。

  3. 你可能会注意到,当我们的角色射击时,可能不会指向正确的方向。这是因为与IdleRun动画相比,角色的方向被修改了,但Base Layer仍然拥有这个控制权。我们可以通过点击Avatar MaskHumanoid部分的人形图底部的圆圈来让UpperBodyMask控制方向,直到它变成绿色:

包含鞋类、卡通、艺术作品的图片  自动生成的描述

图 17.27:赋予掩模对玩家方向的权限

这里的问题是,你现在会看到角色在跑步和射击时将脚向侧面移动。除了修改原始动画外,这里没有简单的解决方案。在这种情况下,这个角色有IdleIdle ShootingRunRun Shooting动画,所以很明显它是在没有考虑到Avatar Masks的情况下创建的,而不是仅仅考虑所有可能的动画组合。一个替代方案是寻找另一个与Avatar Masks配合得更好的包。为了学习目的,我们将坚持使用这个方案,但请注意,Avatar Masks不是必需的;你可能只需要使用单个 Animator Controller 状态机中的所有可能的动画排列,并添加所有需要的转换,就可以很好地进行。

Shoot动画正在播放时,你可能会注意到另一个问题是,炮口效果将保持在武器的原始位置。由于武器网格受到皮肤动画的影响,但不受其Transform位置的影响,炮口无法跟随它。为了解决这个问题,你可以将Muzzle Effect重新分配到武器的一个骨骼上——在这个例子中,是Hips GameObject 的一个子对象Trigger_Right。并非所有动画都会有武器的骨骼,所以这是你可能会遇到的可能场景之一:

包含鞋类、卡通、艺术作品的图片  自动生成的描述

图 17.28:将炮口效果重新分配到武器的一个骨骼上

记得将我们对玩家所做的相同更改应用到敌人身上,这意味着向敌人的Animator组件添加并设置 Player Animator Controller,并更改Muzzle effect的父级。

现在我们已经设置了 Animator Controller 并使用 Avatar Masks,下一步是通过脚本将这些动画在我们的游戏环境中激活。

脚本动画

脚本编写对于将我们的动画与游戏玩法同步至关重要。在这里,我们将编写脚本,使我们的动画能够动态地响应玩家动作,使我们的游戏更加沉浸和响应。

当我们的玩家 Animator Controller 准备就绪时,是时候进行一些脚本编写,使这些参数受到玩家实际行为的影响,并与玩家的行为相匹配。在本节中,我们将执行以下操作以实现这一点:

  • 脚本射击动画

  • 脚本移动动画

让我们开始制作我们的角色在必要时执行射击动画。

编写玩家射击动画脚本

到目前为止,我们已经创建了一个每次按下键时射击的行为,但动画是为持续开火准备的。我们可以让我们的PlayerShooting脚本在保持开火键按下时,每 X 秒发射一颗子弹,以匹配动画,而不是需要反复按下键。

通过添加一个fireRate变量,我们控制射击速度,确保它与我们的动画时间同步。让我们看看如何做到这一点:

  1. PlayerShooting脚本中,添加一个名为fireRate的公共 float 字段,它将测量子弹生成的秒数。请记住在玩家的检查器中设置此值。

  2. OnFire方法更改为图 17.29中看到的代码。我们的想法是在我们按下键时启动重复动作,在我们释放键时停止它。我们正在使用InvokeRepeating来重复执行一个名为Shoot的函数,我们将在下一步创建它。执行速率将由我们在步骤 1中创建的fireRate字段控制:

包含文本、截图、字体、行描述的图片,自动生成

图 17.29:持续开火所需的 OnFire 更改

  1. 图 17.30中看到的Shoot方法添加到我们的PlayerShooting脚本中。这基本上是我们之前在OnFire方法中拥有的相同代码,但现在被分离成一个函数,这样我们就可以通过InvokeRepeating函数多次执行它:

图片

图 17.30:持续开火所需的 OnFire 更改

如果你现在尝试这些更改,你会注意到一旦我们点击开火按钮,子弹将永远不会停止发射。更糟糕的是,随着我们不断按下,发射的子弹会越来越多。通过一些调试或合理的猜测,你可能发现CancelInvoke方法没有被执行。背后的原因是Fire输入映射默认没有配置来通知我们关于键的释放,而是在它们被按下时。幸运的是,解决方案相当简单:

  1. 双击SuperShooter输入资产,这是我们第六章中创建的,它包含我们游戏支持的所有输入。

  2. 动作列表(中间列)中选择开火动作。

  3. 点击交互部分右侧的+按钮,然后点击按下

  4. 按下部分的触发行为设置为按下并释放

图片

图 17.31:持续开火所需的 OnFire 更改

  1. 通过这种方式,我们已经配置了输入,不仅告诉我们何时按下键,还告诉我们何时释放键,使得我们的CancelInvoke方法现在执行。

现在我们有了持续开火的行为,我们可以做以下操作来使动画反映这一点:

  1. Awake中使用GetComponent添加对Animator的引用,并将其缓存在一个字段中,如图 17.32 所示:

图 17.32:缓存 Animator 引用

  1. OnFire方法的开头添加行animator.SetBool(“Shooting”, value.isPressed);

图 17.33:设置射击动画参数以反映输入

  1. 这种更改背后的想法是确保Shooting动画参数反映了火键的状态,这意味着只要按下Fire按钮,就会播放Shoot动画,当我们释放它时,动画会停止。

你会注意到,子弹仍然是从玩家的胸部发射出来的,因为我们定义射击位置的ShootPointGameObject 没有位于武器的前面。只需将ShootPoint重新设置为武器的骨骼(在我们的例子中是Trigger_Right)并将其定位在武器前面。记住,让前向向量(Scene视图中蓝色的箭头)沿着武器方向:

图 17.34:使 ShootPoint 跟随动画

对于视觉脚本版本,为了使子弹能够持续发射,你应该像图 17.35中那样更改PlayerShootingInput节点:

计算机程序截图 自动生成,置信度低

图 17.35:创建射击循环

如你所见,我们使用了一个新的节点叫做TimerTimer的想法与之前我们使用的Wait For Seconds节点类似,因为它允许我们延迟执行一个动作。主要区别在于它允许我们在再次执行之前取消定时器,这意味着我们可以在按下Fire键时启动定时器,并在释放它时停止。我们通过将具有OnPressed模式的InputSystemEventButton节点连接到TimerStart引脚,将具有OnReleased模式的节点连接到Pause引脚来实现这一点。我们还创建了一个名为fireRate的新变量,并将其连接到TimerDuration引脚,因此我们需要指定定时器在实例化子弹之前将等待多长时间。看看我们是如何将TimerCompleted引脚连接到检查我们是否有足够子弹实例化的If节点的;我们之前在这里连接到输入节点。

这里有一点小细节需要注意,当我们按下键时,时间会过去(fireRate),然后实例化一颗子弹,但之后就没有其他动作了。我们需要再次将Bullet射击序列的末尾(在这种情况下是AudioSource: Play节点)连接到TimerStart引脚,以创建一个生成循环。当我们释放键时,这个循环会被中断,以防止它无限执行:

图 17.36:完成射击循环

最后,我们需要向输入节点添加适当的Animator: SetBool(Name, Value)节点来打开和关闭布尔值并触发动画:

计算机屏幕截图,中等置信度自动生成

图 17.37:执行射击动画

现在我们已经配置了玩家的射击动画,让我们应用类似的原则来脚本化敌人的动画,以保持游戏机制的一致性:

  1. 使用GetComponentInParentEnemyFSM脚本中缓存父动画器的引用,就像我们处理NavMeshAgent时做的那样:

包含文本、截图、字体、行描述的图片,自动生成

图 17.38:访问父的 Animator 引用

  1. Shoot函数内部打开Shooting动画器参数,以确保每次射击时该参数都设置为true(已检查):

标志的特写,低置信度自动生成

图 17.39:打开射击动画

  1. 在所有非射击状态中关闭Shooting参数,例如GoToBaseChasePlayer

图片

图 17.40:关闭射击动画

  1. 关于视觉脚本版本,EnemyFSM中的GoToBase状态将如下所示:

图片

图 17.41:GoToBase 状态

  1. 注意,我们再次需要使用GetParent节点来访问敌人的父Transform(即),我们将它与Animator: SetBool节点连接,以便访问敌人根中的 Animator。然后,ChasePlayer状态的动作将如下所示:

图片

图 17.42:ChasePlayer 状态

  1. 然后,AttackBaseAttackPlayer初始动作将如下所示:

计算机屏幕截图,中等置信度自动生成

图 17.43:AttackBase 状态

这样,我们的玩家和敌人都有恒定的射击行为和Shoot动画来反映这一点。现在,让我们处理两者的移动动画。

脚本化移动动画

脚本化移动动画是将游戏角色的物理运动转换为视觉动画的关键。让我们看看我们如何动态调整游戏中的角色速度。

对于 Animator Controller 的Velocity参数,我们可以检测 Rigidbody 的速度向量的幅度,即每秒米的速度,并将其设置为当前值。将此脚本分离出来增强了模块化,并允许在不同游戏场景中更容易地重用。

因此,我们需要创建一个如图所示的脚本,该脚本仅将Rigidbody组件的速度与动画器的Velocity参数连接,并将其添加到 Player GameObject 中:

图片

图 17.44:设置 VelocityAnimator 变量

关于视觉脚本版本,它看起来是这样的:

图 17.45:在视觉脚本中设置速度动画器变量

你可能需要增加 Animator Controller 过渡条件中迄今为止使用的0.01过渡阈值,因为Rigidbody在释放按键后仍然会继续移动。对我来说,使用1效果完美。另一个选择是增加玩家的阻力和速度,使角色更快地停止。选择对你来说效果最好的方法。记住两个层(UpperBodyLowerBody)的过渡。

现在,我们可以为敌人添加移动动画。为敌人预制体创建并添加一个名为NavMeshAnimator的脚本,该脚本将获取其NavMeshAgent的当前速度并将其设置为 Animator Controller。这将与VelocityAnimator脚本类似工作,但这次是检查NavMeshAgent的速度。我们没有使用VelocityAnimator,因为我们的 AI 不使用Rigidbody来移动,所以它不会工作:

计算机程序截图,描述由低置信度自动生成

图 17.46:将 NavMeshAgent 连接到我们的 Animator Controller

视觉脚本版本将看起来像这样:

图 17.47:将动画器的速度参数设置为与我们的 NavMeshAgent 相同

注意,由于这个图位于敌人的根对象旁边,与AnimatorNavMeshAgent一起,所以我们这里不需要GetParent节点。有了这个,我们就已经编写了玩家的动画和敌人的动画脚本。

有时,动画剪辑不足以创建一个可信的动画,因为它们是预定义的,并且不能适应玩家的环境。一个例子是玩家站在楼梯上,玩家的脚应该放在不同的高度,但空闲动画可能是为站在平地上设计的。虽然你可以尝试混合不同高度的脚的动画,但这很难管理。相反,你可以将静态动画(常规动画包)与过程动画混合,这些动画通过脚本修改角色绑定。你可以使用以下 Unity 包实现这一点:docs.unity3d.com/Packages/com.unity.animation.rigging@1.0/manual/index.html

现在,我们准备好继续学习使用 Cinemachine 创建场景相机和更多动画知识。

使用 Cinemachine 创建动态相机

相机在视频游戏中是一个非常重要的主题。它们允许玩家看到他们的周围环境并根据他们所看到的内容做出决策。游戏设计师通常定义它们的行为以获得他们想要的精确游戏体验,这并不容易。必须分层很多行为才能获得精确的感觉。此外,对于场景镜头,控制相机将要穿越的路径以及相机在那些不断移动的场景中聚焦于何处以集中动作也非常重要。

Cinemachine 是 Unity 的高级相机控制套件,使开发者能够制作出动态和响应式的相机行为,从而增强游戏体验。在本章中,我们将使用Cinemachine包来创建动态相机,这些相机将跟随玩家的移动,我们将在第三部分提升视觉效果、效果和音频中编码,以及用于场景镜头的相机。

在本节中,我们将探讨以下 Cinemachine 概念:

  • 创建相机行为

  • 创建轨道推车

让我们先来讨论如何创建一个由 Cinemachine 控制的相机并配置其中的行为。

创建相机行为

Cinemachine 通过提供一系列灵活直观的行为,彻底革新了相机控制,显著简化了在视频游戏中常见的复杂相机设置。

Cinemachine 是一个 Unity 包,包含了一系列可用于相机的不同行为,当正确组合时,可以生成视频游戏中所有种类的常见相机类型,包括从背后跟随玩家、第一人称相机、俯视相机等等。为了使用这些行为,我们需要了解大脑和虚拟相机的概念。

在 Cinemachine 中,我们将只保留一个主相机,就像我们迄今为止所做的那样,而这个相机将由具有上述行为的 GameObject 控制的虚拟相机来控制。我们可以有多个虚拟相机,并且可以随意在它们之间切换,但活动的虚拟相机将是唯一一个将控制我们的主相机。这在游戏的不同阶段切换相机非常有用,例如在玩家的第三人称相机和场景镜头之间切换。为了用虚拟相机控制主相机,它必须有一个Brain组件,该组件将监控所有活动的虚拟相机并选择合适的位姿来使用它们。

要开始使用 Cinemachine,首先,我们需要检查它是否已安装在包管理器中,就像我们之前安装其他包时那样。如果您不记得如何操作,只需按照以下步骤进行:

  1. 前往Window | Package Manager

  2. 确保窗口左上角的Packages选项设置为Unity Registry

图 17.48:包过滤器模式

  1. 稍等片刻,让左侧面板从服务器填充所有包(需要互联网连接)。

  2. 从列表中查找 Cinemachine 包并选择它。在撰写本书时,最新可用的版本是 2.9.7,但如果你更喜欢,可以使用更新的版本,始终确保以下步骤按预期工作;如果不按预期工作,你始终可以安装与我们最接近的版本。

  3. 如果你看到屏幕右下角的 安装 按钮,这意味着它尚未安装。只需点击该按钮。

现在我们已经安装了它,我们可以开始创建一个虚拟相机来跟随玩家。到目前为止,我们只是简单地将相机连接到玩家,使其跟随他们,但现在我们将取消连接相机,让 Cinemachine 处理它,以便学习如何使用这个工具:

  1. 在玩家内部选择 MainCamera 并将其取消连接(将其拖出玩家),使其成为我们场景的根对象,没有任何父对象。

  2. 点击 游戏对象 | Cinemachine | 虚拟相机。这将创建一个名为 CM vcam1 的新对象:

图 17.49:虚拟相机创建

  1. 如果你从 层次结构 窗格中选择 MainCamera,你也会注意到一个 CinemachineBrain 组件已经被自动添加到它上面,使我们的主相机跟随虚拟相机。尝试移动创建的虚拟相机,你会看到主相机是如何跟随它的:

图 17.50:CinemachineBrain 组件

  1. 选择虚拟相机 (CM vcam1) 并将其拖动到 CinemachineVirtualCamera 组件的 跟随注视 属性。这将使移动和注视行为使用该对象来完成它们的工作:

图 17.51:设置我们相机的目标

  1. 你可以看到虚拟相机的 Body 属性被设置为 Transposer,这将使相机相对于在 跟随 属性中设置的目标移动——在我们的案例中,是角色。你可以打开 Body 选项(左侧的箭头),更改 跟随偏移 属性,并将其设置为相机与目标之间的所需距离。在我的案例中,我使用了 03-3 的值:

图 17.52:相机从背后跟随角色

  1. 图 17.52 展示了 游戏 视图;你可以看到一个小的、黄色的矩形,它指示了观察角色的目标位置,目前它指向角色的支点——其脚部。如果你看不到它,请通过点击其左侧的箭头在 检查器 中打开虚拟相机的 瞄准 部分。

  2. 我们可以在虚拟相机 瞄准 部分的 跟踪对象偏移 属性中应用一个偏移。在我的情况下,01.80 的值工作得很好,使相机看向头部:

图 17.53:更改瞄准偏移

如你所见,使用 Cinemachine 非常简单,在我们的情况下,默认设置大多数情况下已经足够满足我们所需的行为。然而,如果你探索其他 BodyAim 模式,你会发现你可以为任何类型的游戏创建任何类型的摄像机。

我们不会在本书中介绍其他模式,但我强烈建议你查看 Cinemachine 的文档,以了解其他模式的功能。要打开文档,请按照以下步骤操作:

  1. 通过转到 Window | Package Manager 来打开包管理器。

  2. 在左侧列表中找到 Cinemachine。如果它没有显示,请稍等片刻。请记住,你需要一个互联网连接才能使其工作。

  3. 一旦选择了 Cinemachine,在右侧面板中向下滚动,直到你看到蓝色的 Documentation 链接。点击它:

图 17.54:Cinemachine 文档链接

  1. 你可以使用左侧的导航菜单来探索文档:

图 17.55:Cinemachine 文档

就像使用 Cinemachine 一样,你可以以相同的方式找到其他包的文档。现在我们已经实现了所需的相机基本行为,让我们探索如何使用 Cinemachine 为我们的开场剪辑创建一个摄像机。

创建摇臂轨道

当玩家开始关卡时,我们希望在进入战斗前有一个小型的场景切换,将镜头从我们的场景和基地上扫过。这需要摄像机沿着固定路径移动,这正是 Cinemachine 的摇臂摄像机所做到的。它创建了一条路径,我们可以在这里附加一个虚拟摄像机,使其跟随路径。我们可以设置 Cinemachine 自动通过轨道或跟随目标到轨道的最近点;在我们的情况下,我们将使用第一种选项。

为了创建一个摇臂摄像机,请按照以下步骤操作:

  1. 让我们从用一辆手推车开始创建轨道,这辆手推车是一个沿着轨道移动的小物体,它将成为跟随摄像机的目标。为此,点击 GameObject | Cinemachine | 带手推车的摇臂轨道

图 17.56:一个默认直线路径的摇臂摄像机

  1. 如果你选择 DollyTrack1 对象,你可以在 Scene 视图中看到两个带有数字 01 的圆圈。这些是轨道的控制点。选择其中一个,并使用平移工具的箭头将其移动,就像移动其他对象一样。如果你看不到它们,请按 W 键启用 Translation 工具。

  2. 你可以通过点击 DollyTrack1 对象的 CinemachineSmoothPath 组件的 Waypoints 列表底部的 + 按钮来创建更多的控制点:

图 17.57:添加路径控制点

  1. 创建所需数量的航点,以创建一个路径,该路径将穿越你希望在开场剪辑中摄像机监督的区域。记住,你可以通过点击它们并使用平移工具来移动航点:

图 17.58:场景中的推车轨道。它正好在角色后面结束

  1. 创建一个新的虚拟相机。创建后,如果你转到游戏视图,你会注意到角色相机将是激活的。为了测试新相机的效果,选择之前的相机(CM vcam1),并通过点击检查器中 GameObject 名称左侧的复选框暂时禁用它。

  2. 这次将跟随目标设置为之前用轨道创建的DollyCart1对象。

  3. 身体部分的跟随偏移设置为000,以保持相机与卡车的相同位置。

  4. 目标设置为与跟随目标相同,使相机朝与卡车相同的方向看,这将跟随轨道的曲线:

图片

图 17.59:配置虚拟相机跟随推车轨道

  1. 选择DollyCart1对象,并更改位置值以查看卡车如何沿着轨道移动。在游戏窗口聚焦且CM vcam2处于独奏模式时进行此操作,以查看相机的效果:

图片

图 17.60:推车组件

  1. 重新启用CM vcam1

在推车轨道设置正确后,我们可以使用时间轴来创建我们的场景,并对其进行序列化。

使用时间轴创建场景

我们已经有了开场相机,但这还不足以创建场景。一个合适的场景是一个动作序列,这些动作将在它们应该发生的确切时刻发生,协调多个对象按预期行事。我们可以有启用和禁用对象、切换相机、播放声音、移动对象等动作。为此,Unity 提供了时间轴,这是一个动作序列器,用于协调这类场景。我们将使用时间轴为我们的场景创建一个开场场景,显示游戏开始之前的水准。

在本节中,我们将探讨以下时间轴概念:

  • 创建动画剪辑

  • 排序我们的开场场景

我们将看到如何在 Unity 中创建自己的动画剪辑来动画化我们的 GameObject,然后将它们放置在场景中,并使用时间轴序列器工具协调它们的激活。让我们先创建一个用于稍后时间轴的相机动画。

创建动画剪辑

这实际上不是一个时间轴特有的功能,而是一个 Unity 功能,它与时间轴配合得很好。当我们下载角色时,它附带了一些使用外部软件创建的动画剪辑,但你可以使用 Unity 的动画窗口创建自定义动画剪辑。不要将其与动画控制器窗口混淆,后者允许我们创建对游戏情况做出反应的动画过渡。这有助于创建小对象特定的动画,你将在稍后使用时间轴与其他对象的动画进行协调。

这些动画可以控制对象组件属性的任何值,例如位置、颜色等。在我们的例子中,我们想要动画化轨道的位置属性,使其在给定时间内从起点到终点移动。为了做到这一点,请执行以下操作:

  1. 选择DollyCart1对象。

  2. 通过转到窗口 | 动画 | 动画打开动画(不是动画器)窗口。

  3. 点击动画窗口中央的创建按钮。记住,在选择了购物车(不是轨道)时执行此操作:

包含文本、字体、软件、屏幕截图的图片,描述自动生成

图 17.61:创建自定义动画剪辑

  1. 执行此操作后,你将被提示将动画剪辑保存到某个位置。我建议你在项目(Assets文件夹内)中创建一个Animations文件夹,并将其命名为IntroDollyTrack

如果你注意的话,购物车现在有一个动画器组件和一个创建的动画控制器,其中包含我们刚刚创建的动画。与任何动画剪辑一样,你需要使用动画控制器将它们应用到你的对象上;自定义动画也不例外。所以,动画窗口为你创建了它们。

在此窗口中动画化包括指定其属性在给定时刻的值。在我们的例子中,我们希望位置在动画开始时,即时间轴上的 0 秒,值为0,在动画结束时,即 5 秒,值为254。我选择254是因为那是我的购物车中可能达到的最后一个位置,但这取决于你的轨道长度。只需测试一下你的最后一个可能的位置是什么。此外,我选择5秒,因为这是我认为动画的正确长度,但你可以随意更改它。现在,动画的 0 到 5 秒之间发生的事情是0254值之间的插值,这意味着在 2.5 秒时,位置的值将是127。动画始终包括在不同时刻插值我们对象的不同状态。

为了做到这一点,请按照以下步骤操作:

  1. 动画窗口中,点击记录按钮(左上角的红色圆圈)。这将使 Unity 检测我们对象中的任何变化并将它们保存到动画中。记住,在选择了购物车时执行此操作。

  2. 将购物车的位置设置设置为1然后0。将此值更改为任何值然后再次设置为0将创建一个关键帧,这是一个动画中的点,表示在0秒时,我们希望位置值为0。如果值已经为0,我们需要首先将其设置为任何其他值。

    你会注意到位置属性已经被添加到动画中:

    图 17.62:将位置值更改为 0 后的记录模式动画

  3. 使用鼠标滚轮,将动画窗口右侧的时间轴缩放到可以看到顶部栏中的5:00秒:

图 17.63

图 17.63:动画窗口的时间轴显示 5 秒

  1. 点击时间轴顶部栏中的5:00秒标签,将播放头定位到那一刻。这将定位我们那一刻所做的下一个更改。

  2. 将推车轨道的位置值设置为可以得到的最高值;在我的情况下,这是240。记得将动画窗口设置为记录模式:

图 17.65

图 17.64:在动画 5 秒时创建具有 240 值的帧

  1. 动画窗口的左上角点击播放按钮以查看动画播放。记得在CM vcam1禁用的情况下在游戏视图中查看。

有时候,你需要将游戏代码与动画同步。一个经典的例子是攻击动画,你希望玩家在剑触地时对被攻击对象造成伤害,而不是动画开始时。为此,你可以使用动画事件:docs.unity3d.com/Manual/script-AnimationWindowEvent.html。你还可以将动画事件添加到导入的动画中:docs.unity3d.com/Manual/class-AnimationClip.html

现在,如果我们点击播放,动画将开始播放,但这不是我们想要的。在这种情况下,我们的想法是将场景控制权交给场景系统、时间轴,因为我们的动画不会是场景中唯一需要排序的东西。防止动画器组件自动播放我们创建的动画的一种方法是在控制器中创建一个空动画状态,并按照以下步骤将其设置为默认状态:

  1. 搜索我们与动画同时创建的动画器控制器并打开它。如果您找不到,只需选择推车并双击我们 GameObject 上的动画器组件的控制器属性以打开资产。

  2. 在控制器中右键点击一个空状态,然后选择创建状态 | 。这将像创建一个新的动画一样在状态机中创建一个新的状态,但这次它是空的:

图 17.65

图 17.65:在动画器控制器中创建空状态

  1. 右键点击新建状态并点击设置为层默认状态。状态应该变成橙色:

图 17.66

图 17.66:将控制器的默认动画更改为空状态

  1. 现在,如果你点击播放,由于我们的推车默认状态为空,将不会播放任何动画。在这种情况下不需要任何过渡。

现在我们已经创建了我们的相机动画,让我们开始创建一个场景,通过使用时间轴,从开场场景相机切换到玩家相机。

编排我们的开场场景

时间轴已经安装到您的项目中,但如果您进入时间轴的包管理器,您可能会看到一个更新按钮,如果您需要一些新功能,可以获取最新版本。在我们的例子中,我们将保持项目中包含的默认版本(1.5.2,本书编写时)。

我们首先要做的是创建一个场景资产和一个负责播放它的场景中的对象。为此,请按照以下步骤操作:

  1. 使用GameObject | 创建空对象选项创建一个空 GameObject。

  2. 选择空对象并将其命名为导演

  3. 前往窗口 | 编排 | 时间轴以打开时间轴编辑器。

  4. 当选择导演对象时,点击时间轴窗口中间的创建按钮,将该对象转换为场景播放器(或导演)。

  5. 完成此操作后,将弹出一个窗口要求您保存文件。此文件将是场景或时间轴;每个场景都将保存在其自己的文件中。将其保存在您项目中的Cutscenes文件夹(Assets文件夹)中。

  6. 现在,您可以看到导演对象有一个可播放导演组件,其中保存了上一步中设置的开场场景资产作为可播放属性,这意味着这个场景将由导演播放:

图片

图 17.67:准备播放开场时间轴资产的可播放导演

现在我们有了可以工作的时间轴资产,让我们来编排动作。首先,我们需要编排两件事——首先,我们在上一步中做的购物车位置动画,然后是摇臂轨道相机(CM vcam2)和玩家相机(CM vcam1)之间的相机交换。正如我们之前所说的,场景是一个在给定时刻执行的动作序列,为了安排动作,你需要轨道。在时间轴中,我们有不同种类的轨道,每一种都允许你在某些对象上执行某些动作。我们将从动画轨道开始。

动画轨道将控制特定对象将播放哪个动画;我们需要为每个对象创建一个轨道来动画化。在我们的例子中,我们想要摇臂轨道播放我们创建的开场动画,所以让我们按照以下步骤操作:

  1. 通过点击加号按钮 (+) 然后选择动画轨道来添加一个动画轨道:

图片

图 17.68:创建动画轨道

  1. 检查器窗口中,选择导演对象并检查可播放导演组件的绑定列表。

  2. 购物车对象拖动以指定我们想要动画轨道控制其动画:图片

    图 17.69:在这个导演中使动画轨道控制摇臂购物车动画

    时间轴是一个通用资产,可以应用于任何场景,但由于轨道控制特定的对象,您需要在每个场景中手动绑定它们。在我们的例子中,我们有一个期望控制单个动画器的动画轨道,因此在每个场景中,如果我们想应用这个剪辑场景,我们需要将特定的动画器拖动到绑定列表中,以控制它。

  3. 将我们创建的Intro动画资产拖动到时间轴窗口中的动画轨道。这将在轨道中创建一个剪辑,显示动画何时以及持续多长时间播放。您可以尽可能多地拖动动画到轨道中,以便在不同的时刻序列不同的动画,但此时我们只想添加一个:

图 17.70:使动画器轨道播放开场剪辑

  1. 您可以拖动动画以更改它播放的确切时刻。将其拖动到轨道的开始位置。

  2. 时间轴窗口的右上角点击播放按钮,以查看其效果。您还可以手动在时间轴窗口中拖动白色箭头,以查看不同时刻的剪辑。如果不起作用,请尝试播放游戏然后停止:

图 17.71:播放时间轴并拖动播放头

现在,我们将使我们的Intro时间轴资产告诉CinemachineBrain组件(主摄像头)在剪辑的每个部分中哪个摄像头是活动的,摄像头动画结束后切换到玩家摄像头。我们将创建第二个轨道——一个 Cinemachine 轨道,它专门用于在特定的CinemachineBrain组件之间切换不同的虚拟摄像头。为此,请按照以下步骤操作:

  1. 再次点击+按钮,然后点击Cinemachine 轨道。请注意,您可以在没有Cinemachine的情况下安装时间轴,但那种情况下不会出现这种类型的轨道:

图 17.72:创建一个新的 Cinemachine 轨道

  1. 可播放导演组件的绑定列表中,将主摄像头拖动到Cinemachine 轨道,使其跟踪控制在不同时刻的剪辑中哪个虚拟摄像头将控制主摄像头:

图 17.73:将主摄像头绑定到 Cinemachine 轨道

  1. 下一步指示在时间轴的特定时刻将激活哪个虚拟摄像头。为此,我们的 Cinemachine 轨道允许我们将虚拟摄像头拖动到它上面,这将创建虚拟摄像头剪辑。按照顺序,将CM vcam2CM vcam1拖动到 Cinemachine 轨道:

图 17.74:将虚拟摄像头拖动到 Cinemachine 轨道

  1. 如果您点击播放按钮或只是拖动时间轴播放头,您可以看到当播放头到达第二个虚拟摄像头剪辑时,活动虚拟摄像头如何变化。请记住在游戏视图中查看这一点。

  2. 如果你将鼠标放在剪辑的末端附近,会出现一个调整大小光标。如果你拖动它们,你可以调整剪辑的大小以指定它们的持续时间。在我们的例子中,我们需要将 CM vcam2 剪辑的长度与 Cart 动画剪辑匹配,然后通过拖动将其放置在末尾,这样当推车动画结束时,摄像机就会变得活跃。在我的情况下,它们的长度已经相同,但只是为了练习,还是尝试改变它。此外,你还可以使 CM vcam1 剪辑更短;我们只需要播放几秒钟来执行摄像机交换。

  3. 你也可以稍微重叠剪辑,以在两个摄像机之间创建平滑的过渡,而不是生硬的切换,这样看起来会很奇怪:

图 17.75:调整大小和重叠剪辑以进行插值

  1. WaveSpawners开始时间 属性增加,以防止在场景剪辑开始之前生成敌人。

如果你等待完整的场景剪辑结束,你会注意到,在最后,CM vcam2 再次变得活跃。你可以配置时间轴如何处理场景剪辑的结束,因为默认情况下,它什么都不做。这可能会导致不同类型的轨道有不同的行为——在我们的例子中,再次将选择虚拟摄像机的控制权交给 CinemachineBrain 组件,该组件将选择具有最高 优先级 值的虚拟摄像机。我们可以更改虚拟摄像机的 优先级 属性,以确保 CM vcam1(玩家摄像机)始终是更重要的一方,或者将 可播放导演 组件的 Wrap Mode 设置为 Hold,这将保持一切如时间轴最后帧所指定的那样。在我们的例子中,我们将使用后者选项来测试时间轴特定的功能:

图 17.76:Wrap Mode 设置为 Hold 模式

大多数不同类型的轨道都遵循相同的逻辑;每个轨道都将控制特定对象的一个特定方面,使用在设定时间内执行的剪辑。我鼓励你测试不同的轨道,看看它们能做什么,例如 激活,它可以在场景剪辑期间启用和禁用对象。记住,你可以在包管理器中查看时间轴包的文档。

摘要

在本章中,我们介绍了 Unity 为不同需求提供的不同动画系统。我们讨论了导入角色动画以及使用动画控制器来控制它们。我们还看到了如何制作能够对游戏当前情况做出反应的相机,例如玩家的位置,或者可以在场景中使用的相机。最后,我们探讨了 Timeline 和动画系统,为我们的游戏创建一个开场场景。这些工具对于让我们的团队动画师直接在 Unity 中工作非常有用,无需集成外部资产(除了角色动画)的麻烦,同时也防止程序员创建重复的脚本来创建动画,从而节省时间。

现在,您可以在 Unity 中导入和创建动画剪辑,并将它们应用到 GameObject 上,使它们根据剪辑移动。您还可以将它们放置在 Timeline 序列器中,协调它们并为游戏创建场景。最后,您可以创建动态相机,用于游戏或场景中。

通过这种方式,我们结束了第二部分,掌握编程和游戏玩法机制,在这一部分中,我们学习了不同的 Unity 系统来提高我们游戏的艺术方面。在下一章,第三部分的第一章,提升视觉效果、效果和音频中,我们将完成我们游戏的开发,并了解如何构建和优化它,我们还将提供增强现实应用的快速介绍。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提问、为其他读者提供解决方案、通过“问我任何问题”的环节与作者聊天,以及更多。扫描二维码或访问链接加入社区:

packt.link/unitydev

第十八章:性能魔法:使用 Profiler 工具优化你的游戏

欢迎来到本书的第四部分——我很高兴你到达了这一部分,这意味着你几乎完成了一个完整游戏!到目前为止,我们开发了一个游戏,同时专注于实现不同的游戏玩法功能和效果,但我们没有考虑它们在游戏每秒帧数FPS)方面的表现如何。在本章中,我们将讨论优化技术来审查你的游戏性能并提高它,因为保持良好的和稳定的帧率对任何游戏都是至关重要的。

性能是一个广泛的话题,需要深入理解几个 Unity 系统,可能涉及几本书的内容。我们将探讨如何衡量性能,并探索我们对系统所做的更改的影响,以通过测试了解它们是如何工作的。

在本章中,我们将检查以下性能概念:

  • 优化图形

  • 优化处理

  • 优化内存

到本章结束时,你将能够收集运行你的游戏的三种主要硬件的性能数据——GPU、CPU 和 RAM。你将能够分析这些数据以检测可能存在的性能问题,并了解如何解决最常见的那些问题。

我们将首先学习如何优化我们游戏中的图形方面。

优化图形

性能问题的最常见原因是与资产的误用有关,尤其是在图形方面,由于对 Unity 的图形引擎工作原理了解不足。我们将探讨 GPU 在高级别是如何工作的,以及如何提高其使用效率。

在本节中,我们将检查以下图形优化概念:

  • 图形引擎简介

  • 使用帧调试器

  • 使用批处理

  • 其他优化

我们将首先了解图形是如何渲染的,以便更好地理解我们将在帧调试器中收集的性能数据。根据调试器的结果,我们将确定可以应用批处理(这是一种结合多个对象渲染过程的技术,可以降低其成本)以及其他需要记住的常见优化。

图形引擎简介

现在,每个游戏设备,无论是电脑、移动设备还是游戏机,都有一个显卡——一组专门从事图形处理的硬件。它与 CPU 在细微但重要的方面有所不同。图形处理涉及处理数千个网格顶点和在单个帧中渲染数百万个像素。这是计算你的对象像素的颜色和光照,以及移动和动画化你的对象几何形状的责任。GPU 被设计成运行短程序大量次数,而 CPU 可以处理任何长度的程序,但并行化能力有限。拥有那些处理单元(CPU 和 GPU)的原因是,我们的程序可以在需要时使用每一个。

这里的问题是图形不仅仅依赖于 GPU。CPU 也参与了这个过程,进行计算并向 GPU 发出命令,因此它们必须协同工作。为了实现这一点,这两个处理单元需要通信,因为它们通常是物理上分开的,所以它们需要另一块硬件来实现这一点:总线,其中最常见的是外围组件互连扩展PCI Express)总线。

PCI Express是一种连接类型,允许在 GPU 和 CPU 之间移动大量数据,但问题是即使它非常快,如果你在这两个单元之间发出大量命令,通信时间也可能明显。因此,这里的关键概念是,图形性能的提高主要在于减少 GPU 和 CPU 之间的通信:

包含文本、字体、截图的图片,自动生成描述

图 18.1:通过 PCI Express 总线进行的 CPU/GPU 通信

现在,新的硬件架构允许 CPU 和 GPU 在同一芯片组中共存,减少通信时间,甚至共享内存。遗憾的是,这种架构不允许视频游戏所需的处理能力,因为将这两部分分开可以让它们有足够的空间容纳大量核心。

图形引擎的基本责任是使用剔除算法确定哪些对象是可见的,根据它们的相似性对它们进行排序和分组,然后向 GPU 发出绘制命令以渲染这些对象组,有时甚至不止一次。CPU 和 GPU 之间主要的通信形式是绘制命令,通常称为绘制调用,我们在优化图形时的主要任务是尽可能减少它们。问题是存在多个需要考虑的绘制调用来源,例如光照或某些特殊效果。研究每一个都需要花费很长时间,即使如此,Unity 的新版本也可以引入带有自己绘制调用的新图形功能。相反,我们将探索使用帧调试器发现这些绘制调用的方法。

使用帧调试器

帧调试器是一个工具,它允许我们查看 Unity 渲染引擎发送给 GPU 的所有绘制调用的列表。它不仅列出它们,还提供了关于每个绘制调用的信息,包括检测优化机会所需的数据。通过使用帧调试器,我们可以看到我们的更改如何修改绘制调用的数量,从而立即获得关于我们努力的反馈。

注意,减少绘制调用有时并不足以提高性能,因为每个绘制调用可能有不同的处理时间,但通常这种差异并不大,不值得考虑。另外,在某些特殊的渲染技术中,例如光线追踪或光线行进,单个绘制调用可能会耗尽我们所有的 GPU 功率。在我们的游戏中不会出现这种情况,所以我们现在不考虑这一点。

让我们通过以下步骤使用帧调试器来分析我们游戏的渲染过程:

  1. 打开帧调试器(窗口 | 分析 | 帧调试器)。

  2. 播放游戏,当您想要分析性能时,点击帧调试器左上角的启用按钮(在播放时按Esc键以恢复鼠标控制):

图形用户界面,应用程序描述自动生成

图 18.2:启用帧调试器

  1. 点击游戏选项卡以打开游戏视图。

  2. 禁用按钮右侧的滑块从左到右缓慢移动,以查看场景是如何渲染的。每一步都是一个正在 CPU 上执行该游戏帧的绘制调用。您还可以观察窗口左侧的列表如何突出显示此时正在执行的绘制调用的名称:

图形用户界面,应用程序描述自动生成

图 18.3:分析我们的帧绘制调用

  1. 如果列表中的某些绘制调用在游戏面板中输出灰色图像,同时在控制台中显示警告,则对此的临时修复方法是选择您场景的主要相机,并将其MSAA属性在相机组件的输出部分设置为关闭。请记住,之后使用帧调试器撤销此更改。

  2. 点击列表中的任何绘制调用,并观察窗口右侧的详细信息。

    如果您不习惯于编码引擎或着色器,其中大部分可能会让您感到困惑,但您可以看到其中一些具有可读部分,说明了为什么这个绘制调用不能与上一个一起批处理,这告诉您为什么两个对象没有在单个绘制调用中一起绘制。我们将在稍后检查这些原因:

    文本描述自动生成

    图 18.4:帧调试器中的批处理中断原因

  3. 播放模式下打开窗口,禁用地形,看看绘制调用数量的变化是否立即发生。有时,仅仅打开或关闭对象就足以检测到导致性能问题的原因。还可以尝试禁用后期处理和其他与图形相关的对象,例如粒子。

即使我们对每个 draw call 的来源并不完全清楚,我们至少可以从修改 Unity 中的设置开始,看看这些变化的影响。没有比通过遍历每个切换并使用测量工具查看这些变化的影响更好的方式来发现像 Unity 这样庞大的系统是如何工作的了。当然,有时我们可能需要付出某些 draw call 的代价来实现某些效果,比如在地面场景中,尽管你总是可以质疑这是否值得;这需要逐个案例进行分析。

即使 Frame Debugger 提供了大量信息,有时你可以额外走一步,使用更高级的工具,如 RenderDoc 或 NVIDIA Nsight,其中一些工具在功能上与 Frame Debugger 相似,即它们显示所有的 draw call,但同时也显示每个 draw call 的时序、网格、着色器和每个 draw call 使用的纹理等信息。

现在,让我们讨论减少 draw call 的基本技术,并看看它们在 Frame Debugger 中的效果。

使用批处理

我们在前面章节讨论了几种优化技术,其中光照是最重要的。如果你在实现这些技术时测量 draw call,你会注意到这些操作对 draw call 数量的影响。然而,在本节中,我们将专注于另一种图形优化技术,称为批处理。批处理是将多个对象分组在一起,以便在单个 draw call 中绘制它们的过程。

你可能想知道为什么我们不能仅仅在一个 draw call 中绘制所有内容,虽然这在技术上是可以实现的,但为了合并两个对象,需要满足一系列条件,通常情况是合并材质。

记住,材质作为图形预设,指定了一个材质模式或着色器以及一组参数来定制我们对象的外观,比如对象的颜色和纹理。如果 Unity 需要绘制一个与之前不同的材质的对象,在发出 draw call 之前需要调用SetPass,这是另一种 CPU/GPU 通信形式,用于在 GPU 上设置材质属性,如其纹理和颜色。如果两个对象使用相同的材质,这一步可以跳过。第一个对象的SetPass调用会被第二个对象重用,这为批处理对象打开了机会。如果它们共享相同的设置,Unity 可以在 CPU 中将网格合并成一个,然后通过单个 draw call 将合并后的网格发送到 GPU。

减少材料数量的方法有很多,例如去除重复项,但最有效的方法是通过一个称为纹理图集的概念。这意味着将不同对象的纹理合并成一个。这样,由于使用的纹理可以应用于多个对象,因此几个对象可以使用相同的材质。遗憾的是,Unity 中没有自动系统来合并三维对象的纹理,例如我们在 2D 中使用的 Texture Atlas 对象。可能有一些系统在 Asset Store 中,但自动系统可能会有一些副作用。

这项工作通常由艺术家完成,因此在与专门的 3D 艺术家(或如果你是自己)工作时,只需记住这个技巧:

包含屏幕截图的图片  描述自动生成

图 18.5:不同金属物体的碎片

让我们通过以下步骤使用帧调试器探索批处理:

  1. 前往编辑 | 首选项 | 核心渲染管线并将可见性设置为所有可见。这将允许我们看到基本和高级图形设置:

计算机屏幕截图  描述自动生成,置信度中等

图 18.6:启用显示所有可用图形设置

  1. 搜索我们目前想要使用的可脚本渲染管线设置资产(编辑 | 项目设置 | 图形 | 可脚本渲染管线设置):

计算机屏幕截图  描述自动生成,置信度中等

图 18.7:可脚本渲染管线设置

  1. 现在,在渲染部分取消选中SRP 批处理器并选中动态批处理。我们将在本章后面重新启用它,以便更好地理解为什么我们应该始终使用SRP 批处理器

计算机屏幕截图  描述自动生成,置信度中等

图 18.8:禁用 SRP 批处理器

  1. 为测试创建一个新的空场景(文件 | 新建场景)。

  2. 创建两种不同颜色的材质。

  3. 创建两个立方体,并将一个材质放入第一个,另一个放入第二个。

  4. 打开帧调试器并点击启用以查看我们立方体绘制调用的调用列表:

文本  描述自动生成

图 18.9:立方体的绘制调用

  1. 选择第二个绘制网格立方体调用,并查看批处理中断的原因。它应该说明对象具有不同的材质。

  2. 将其中一种材质应用于两个立方体,并再次查看列表。你会注意到现在我们只有一个绘制网格立方体调用。如果你没有在玩游戏,可能需要再次禁用和启用帧调试器以正确刷新。

现在,我挑战你尝试相同的步骤,但用球体而不是立方体来创建。在执行这个挑战的过程中,注意你的观察和假设,关于为什么立方体和球体之间的结果不同。如果你这样做,你可能会注意到,即使使用相同的材质,球体也没有被批处理!这就是我们需要引入 动态批处理 概念的地方。

记住,GameObject 有一个 静态 复选框,它用于通知几个 Unity 系统,该对象不会移动,这样它们就可以应用一些优化。未勾选此复选框的对象被认为是动态的。到目前为止,我们用于测试的立方体和球体一直是动态的,所以 Unity 需要每帧将它们组合起来,因为它们可以移动,而组合并不是“免费的”。它的成本直接与模型中的顶点数相关。

你可以从 Unity 手册中获取确切数字和所有必要的考虑因素,你可以在互联网上搜索 Unity Batching 来找到它,或者你可以通过这个链接访问:docs.unity3d.com/Manual/DrawCallBatching.html。然而,只需说如果对象的顶点数量足够大,那么该对象就不会被批处理,这样做将需要发出超过两个绘制调用。这就是为什么我们的球体没有被批处理;球体有太多的顶点。

现在,如果我们有静态对象,情况就不同了,因为它们使用第二个批处理系统——静态批处理器。这个概念是相同的。合并对象以在一个绘制调用中渲染它们,并且同样,这些对象需要共享相同的材质。主要区别在于,这个批处理器将批处理比动态批处理器更多的对象,因为合并是在场景加载时一次性完成的,然后保存在内存中以供下一帧使用,这虽然消耗内存,但每帧都能节省大量的处理时间。你可以使用与我们用来测试动态批处理相同的方法来测试静态版本,只需这次检查球体的静态复选框,并在播放模式下查看结果;在编辑模式(当它没有播放时),静态批处理器不会工作:

图形用户界面,文本,应用程序  自动生成的描述

图 18.10:一个静态球体及其静态批次

在继续之前,让我们讨论一下为什么我们禁用了 SRP Batcher 以及这如何改变我们刚才讨论的内容。在 2020 版本中,Unity 引入了 通用渲染管线URP),一个新的渲染管线。

除了几个改进之外,目前相关的一个是 SRP Batcher,这是一个新的批处理器,它可以在没有顶点或材质限制(但有其他限制)的动态对象上工作。SRP Batcher 不是依赖于与批处理对象共享相同的材质,而是可以有一个使用相同着色器和变体的对象批处理,这意味着我们可以有,例如,100 个对象,每个对象有 100 种不同的材质,并且它们将根据材质使用相同的着色器和变体进行批处理,无论顶点数量多少:

包含文本、标志、截图的图片,自动生成描述

图 18.11:材质的 GPU 数据持久性,允许 SRP Batcher 存在

一个着色器可以有多个版本或变体,选择的变体基于设置。我们可以有一个不使用法线贴图的着色器,以及一个不计算法线的变体将被使用,这可能会影响 SRP Batcher。所以,使用 SRP Batcher 基本上没有缺点,所以请再次开启它。尝试创建尽可能多的球体,使用尽可能多的材质,并在帧调试器中检查它将生成的批次数。只是考虑一下,如果你需要处理在 URP 之前完成的项目,这个功能将不可用,因此你需要知道适当的批处理策略来使用。

其他优化

如前所述,有很多可能的图形优化,让我们简要讨论基本的一些,从细节级别LOD)开始。LOD 是根据对象与摄像机的距离改变对象网格的过程。如果你在房子远离时用具有较少细节的单个组合网格替换由几个部分和部件组成的房子,这可以减少绘制调用。使用 LOD 的另一个好处是,由于顶点数量的减少,可以减少绘制调用的成本。

要使用此功能,请执行以下操作:

  1. 创建一个空对象,并将模型的两个版本作为父对象。你需要使用具有不同细节级别的多个版本的模型,但到目前为止,我们只是将使用一个立方体和一个球体来测试这个功能:

包含图表的图片,自动生成描述

图 18.12:具有两个 LOD 网格的单个对象

  1. LOD 组组件添加到父对象上。

  2. 默认 LOD 组已准备好支持三个 LOD 网格组,但因为我们只有两个,所以右键单击一个,然后点击删除。你也可以选择插入之前来添加更多的 LOD 组:

漏斗图,自动生成描述,置信度低

图 18.13:删除 LOD 组

  1. 选择LOD 0,最高细节级别的 LOD 组,然后在下面的渲染器列表中点击添加按钮,将球体添加到该组。你可以添加任意数量的网格渲染器。

图形用户界面,自动生成描述

图 18.14:向 LOD 组添加渲染器

  1. 选择 LOD 1 并添加立方体。

  2. 拖动两个组之间的线来控制每个组将占据的距离范围。当你拖动时,你会看到相机需要移动多远才能切换组。此外,你还有 剔除 组,这是相机不会渲染任何组的距离。

  3. 只需在 场景 面板中移动场景,就可以看到网格是如何切换的。

  4. 这里需要考虑的是,对象的碰撞器不会被禁用,所以只需在 LOD 子对象中放置渲染器。将 LOD 0 的形状的碰撞器放在父对象中,或者只需从 LOD 组对象中移除碰撞器,除了组 0。

另一个可以考虑的优化是 视锥剔除。默认情况下,Unity 会渲染任何落在相机视图区域或视锥体内的对象,跳过那些不在的对象。该算法足够便宜,可以始终使用,而且无法禁用它。然而,它确实有一个缺陷。如果我们有一个墙隐藏了它后面的所有对象,即使它们被遮挡,它们也会落在视锥体内,所以它们仍然会被渲染。在实时中检测网格的每个像素是否遮挡了另一个网格的每个像素几乎是不可能的,但幸运的是,我们有一个解决方案:遮挡剔除。

遮挡剔除 是一个分析场景并确定场景不同部分中可以看到哪些对象的过程,将它们分成区域并分析每一个。由于这个过程可能需要相当长的时间,它是在编辑器中完成的,类似于光照贴图。正如你可以想象的那样,它只适用于静态对象,因为它是计算在编辑器时间内的。要使用它,请执行以下操作:

  1. 将不应移动的对象标记为静态,或者如果你只想让这个对象在遮挡剔除系统中被视为静态,请检查 静态 复选框右侧箭头的 遮挡者静态遮挡物静态 复选框。遮挡者表示该对象可以遮挡其他对象,而遮挡物表示该对象可以被其他遮挡者对象遮挡。我们正在设置对象进行遮挡和被遮挡。

    为了获得额外的性能,你可能想要考虑哪些对象是遮挡者,哪些是遮挡物。小对象不太可能遮挡其他对象,所以可能不值得检查那些对象的遮挡者标志。同样,根据你的场景设置,可能根本不会被遮挡的大对象可能是不被遮挡物的良好候选者。

  2. 打开 遮挡剔除 窗口(窗口 | 渲染 | 遮挡剔除)。

  3. 保存场景,点击窗口底部的 烘焙 按钮,然后等待烘焙过程。如果你在烘焙过程之前没有保存场景,它将不会执行。

  4. 遮挡剔除 窗口中选择 可视化 选项卡。

  5. 遮挡剔除窗口可见时,选择相机(或 Cinemachine 控制的虚拟相机的情况)并拖动它,观察随着相机移动物体是如何被遮挡的:

包含户外、飞机的图片 自动生成的描述

图 18.15:左侧是正常场景,右侧是遮挡剔除后的场景

考虑到如果您将相机移动到计算区域之外,该过程将不会进行,Unity 将只计算静态物体附近的区域。您可以通过创建一个空对象并添加遮挡区域组件来扩展计算区域,设置其位置和大小以覆盖相机将到达的区域,最后,重新烘焙剔除。尽量对立方体的尺寸保持明智。要计算的面积越大,您在磁盘上存储生成的数据所需的空间就越大。

您可以使用这些区域中的几个来更精确地操作——例如,在 L 形场景中,您可以使用其中的两个:

图表 自动生成的中等置信度描述

图 18.16:遮挡区域

如果您看到物体没有被遮挡,可能是因为遮挡物对象(在这种情况下是墙壁)不够大,不能被考虑。您可以增加对象的大小或减少窗口烘焙选项卡中的最小遮挡物设置。这样做将进一步细分场景以检测小遮挡物,但这将在磁盘上占用更多空间来存储更多数据。所以,再次,对此设置要明智。

我们还可以应用更多技术到我们的游戏中,但我们讨论的这些已经足够我们的游戏使用。因此,在本节中,我们学习了在显卡中渲染图形的过程,批处理的概念,如何分析它们以确切知道我们有多少个以及它们在做什么,最后,如何尽可能减少它们。现在,让我们开始讨论其他优化领域,例如处理领域。

优化处理

虽然图形通常需要占用生成帧所需的大部分时间,但我们绝不能低估糟糕的代码和场景的成本。游戏中有几个部分仍在 CPU 中计算,包括图形处理的一部分(如批处理计算)、物理、音频以及我们的代码。在这里,我们比图形方面有更多的性能问题,所以,再次,我们不是讨论每一个优化,而是学习如何发现它们。

在本节中,我们将检查以下 CPU 优化概念:

  • 检测 CPU 和 GPU 限制

  • 使用CPU 使用率分析器

  • 通用 CPU 优化技术

我们将首先讨论 CPU 和 GPU 限制的概念,这些概念侧重于优化过程,确定问题是否与 GPU 或 CPU 相关。稍后,就像 GPU 优化过程一样,我们将探讨如何收集 CPU 的性能数据并解释它以检测可能应用的优化技术。

检测 CPU 和 GPU 限制

与帧调试器一样,Unity 分析器允许我们通过一系列分析器模块收集关于游戏性能的数据,每个模块都设计用于收集每帧不同 Unity 系统的数据,例如物理、音频,最重要的是CPU 使用情况。这个最后的模块允许我们看到 Unity 执行以处理帧的最重要操作——从我们的脚本到物理和图形等系统(CPU 部分)。

在探索CPU 使用情况之前,我们可以在本模块收集的一个重要数据点是,我们是否受 CPU 或 GPU 限制。正如之前所解释的,一帧的处理使用 CPU 和 GPU,这些硬件可以并行工作。当 GPU 正在执行帧的绘制命令时,CPU 可以非常高效地执行下一帧的物理、我们的脚本和其他非图形过程。但是,现在假设 CPU 在 GPU 仍在处理前一帧时完成了这项工作。CPU 能否开始处理下一帧的图形处理?答案是不能。这会导致不同步,因此在这种情况下,CPU 需要等待。如果您的游戏已经以期望的帧率(通常是 60fps)运行,这种等待并不一定是坏事,但如果不是,您的游戏性能将受到限制或绑定。我们描述的 CPU 等待 GPU 的情况称为 GPU 限制,我们还有相反的情况,即 CPU 限制,当 GPU 比 CPU 先完成时。当 CPU 和 GPU 并行工作时,存在 CPU 需要 GPU 但 GPU 正忙的时刻,反之亦然,导致等待时间只是硬件能力的浪费。

重要的是集中我们的优化努力,所以如果我们检测到我们的游戏是 GPU 限制,我们将专注于 GPU 图形优化(如减少网格和着色器复杂性),如果是 CPU 限制,那么我们将专注于其他系统和图形处理的 CPU 方面。为了检测我们的游戏是哪一种,请执行以下操作:

  1. 打开分析器(窗口 | 分析 | 分析器)。

  2. 在左上角的分析器模块下拉菜单中,勾选GPU 使用情况以启用 GPU 分析器:

表格描述自动生成

图 18.17:启用 GPU 分析器

  1. 播放游戏并选择CPU 使用情况分析器,在分析器窗口的左侧点击其名称。

  2. 点击最后一帧按钮,即指向右的双箭头按钮,以始终显示正在渲染的最后一帧的信息:

包含日历的图片  自动生成的描述

图 18.18:最后一帧按钮(向右的双箭头)

  1. 还需点击 Live 按钮以启用 Live 模式,这允许您实时查看分析结果。这可能会影响性能,因此您可以稍后禁用它:

图形用户界面,文本,应用程序  自动生成的描述

图 18.19:启用 Live 模式

  1. 观察窗口中间带有 CPUGPU 标签的条形图。它应该说明 CPU 和 GPU 消耗了多少毫秒。数值较高的那个将是限制我们帧率的那个,并决定我们是 GPU-还是 CPU-限制:

包含表格的图片  自动生成的描述

图 18.20:确定我们是 CPU-还是 GPU-限制

  1. 有可能当您尝试打开 GPU 分析器时,您会看到一条不支持的消息,这种情况可能发生在某些情况下(例如,在使用 Metal 图形 API 的 Mac 设备上)。在这种情况下,另一种查看我们是否处于 GPU 限制状态的方法是在选择 CPU 使用率 分析器的同时,在 CPU/GPU 标签旁边的搜索栏中搜索 waitforpresent。如果您看不到搜索栏,请点击 Live(应显示为 时间轴)左侧的下拉菜单并选择 层次结构

图 18.21:搜索 waitforpresent

  1. 在这里,您可以查看 CPU 等待 GPU 的时间长度。检查 时间 ms 列以获取数字。如果您看到 0.00,则表示 CPU 不在等待 GPU,这意味着我们处于 CPU 限制状态。在前面的屏幕截图中,您可以看到我的屏幕显示 0.00,而 CPU 需要 6.42ms,GPU 需要 2.17ms。因此,我的设备是 CPU 限制的,但请考虑您的设备和项目可能会带来不同的结果。

现在我们能够检测到我们是 CPU-还是 GPU-限制,我们可以将优化努力集中在 CPU 端优化或 GPU 端优化。到目前为止,我们在 优化图形 部分讨论了如何分析并优化 GPU 过程的一部分。现在,如果我们检测到我们处于 CPU 限制状态,让我们看看如何分析 CPU。

使用 CPU 使用率分析器

分析 CPU 的方式与分析 GPU 类似。我们需要获取 CPU 执行的动作列表,并尝试减少它们的数量,或者至少减少它们的成本。这就是CPU 使用性能分析器模块发挥作用的地方——这是一个工具,它允许我们查看 CPU 在一个帧中执行的所有指令。主要区别在于 GPU 主要执行绘制调用,我们只有几种类型,而 CPU 可以执行数百种不同的指令,有时其中一些指令不能删除,例如物理或音频处理。在这些情况下,我们希望减少这些函数的成本,以便它们不会消耗太多时间。因此,在这里的一个重要提示是检测哪个函数花费了太多时间,然后减少其成本或删除它,这需要更深入地了解底层系统。让我们首先开始检测函数。

当您在打开性能分析器标签的情况下玩游戏时,您将看到一系列显示游戏性能的图形,在CPU 使用性能分析器中,您将看到图形被分成不同的颜色,每个颜色都指代帧处理的不同部分。您可以通过查看性能分析器左侧的信息来了解每种颜色的含义,但让我们讨论最重要的几个。

检查以下 Unity 手册页面以了解如何为性能分析器启用色盲模式:docs.unity3d.com/2023.1/Documentation/Manual/ProfilerWindow.html

在下面的屏幕截图中,您可以查看图形应该如何显示:

图表描述自动生成

图 18.22:分析 CPU 使用图

如果您看到图形,您可能会认为图表中深绿色的部分占据了大部分的性能时间,虽然这是真的,但您也可以从图例中看到,深绿色代表其他,这是因为我们在编辑器中分析游戏。编辑器不会完全像最终游戏那样表现。为了运行它,它必须执行许多额外的处理,这些处理在游戏中不会执行,因此您能做的最好的事情就是直接在游戏的构建中进行分析。在那里,您将收集更准确的数据。我们将在下一章讨论如何进行构建,所以现在我们可以忽略那个区域。我们现在能做的就是简单地点击其他标签左侧的彩色方块,以从图中禁用该测量,以便稍微清理一下。如果您也看到一个很大的黄色部分,它指的是垂直同步,这基本上是我们等待处理与监视器刷新率匹配的时间。这也是我们可以忽略的事情,所以您也应该禁用它。在下一张屏幕截图中,您可以检查图形颜色类别以及如何禁用它们:

图形用户界面,应用程序描述自动生成

图 18.23:从性能分析器禁用 VSync 和其他设置

现在我们已经清理了图表,我们可以通过查看带有ms标签的线条(在我们的案例中,5ms(200FPS))来了解我们游戏潜在帧率的良好概念,这表明低于该线条的帧有超过 200FPS,而高于该线条的帧则较少。

在我的情况下,我拥有出色的性能,但请记住,我是在一台强大的机器上测试这个的。最佳的性能分析方式不仅是在游戏的构建(作为一个可执行文件)中,而且是在目标设备上,这应该是我们打算让游戏运行的最低规格硬件。我们的目标设备很大程度上取决于游戏的目标受众。如果我们正在制作休闲游戏,我们可能针对的是移动设备,因此我们应该在最低规格的手机上测试游戏,但如果我们的目标是针对核心玩家,他们可能有一台强大的机器来运行我们的游戏。

如果你针对的是核心玩家,当然,这并不意味着我们可以因为这一点而制作一个非常未优化的游戏,但这将给我们足够的空间来添加更多细节。无论如何,我强烈建议如果你是初学者,避免那些类型的游戏,因为它们更难开发,你可能很快就会意识到这一点。一开始就坚持简单的游戏。

通过查看图形颜色,你可以观察到在渲染的 CPU 侧的成本以浅绿色表示,图表显示它占用了相当一部分的处理时间,这实际上是正常的。然后,在蓝色中,我们可以看到我们的脚本和其他系统执行的成本,这也占用了相当一部分,但同样,这也是相当正常的。此外,我们还可以观察到一点橙色,这是物理,还有一点浅蓝色,这是动画。请记住检查性能分析器中的彩色标签,以记住哪个颜色代表什么。

现在,这些彩色条代表了一组操作,如果我们认为渲染条代表 10 个操作,我们如何知道这包括哪些操作?同样,我们如何知道这些操作中哪一个占用了最多的性能时间?在这 10 个操作中,任何一个都可能是导致这些问题的原因。这就是性能分析器底部部分有用的地方。它显示了一个在帧中被调用的所有函数的列表。要使用它,请执行以下操作:

  1. 点击性能分析器中CPU 使用率部分的任何部分,并检查性能分析器底部栏左上角的按钮是否显示为层次结构。如果不是(例如,如果显示为时间线),请点击它并选择层次结构

  2. 清除我们之前使用的搜索栏。它将通过名称过滤函数调用,而我们想看到所有这些调用。

  3. 点击时间 ms列,直到你看到一个指向下方的箭头。这将按成本降序排列调用。

图 18.24:Profiler 时间毫秒列

  1. 点击图表中引起你注意的帧——可能是那些高度最大且消耗更多处理时间的帧之一。这将使 Profiler 立即停止游戏并显示有关该帧的信息。

    在查看图表时,有两个方面需要考虑。如果你看到高于其他帧的峰值,这可能会在游戏中造成中断——一个非常短暂的瞬间,游戏会冻结,这可能会破坏性能。此外,你可以寻找长时间消耗较高的帧序列。尽量减少它们。即使这只是一个临时的解决方案,但玩家很容易就能感知到它的影响,尤其是在 VR 游戏中,因为这可能会引起恶心。

  2. PlayerLoop可能看起来是最耗时的帧,但这并不很有信息量。你可以通过点击其左侧的箭头来展开它进一步探索。

  3. 点击每个函数以在图表中突出显示它。处理时间较长的函数将以较粗的条形显示,我们将重点关注这些函数:

表格描述自动生成,置信度中等

图 18.25:图表中突出显示的渲染相机函数

  1. 你可以继续点击箭头以进一步探索函数,直到达到限制。如果你想深入了解,可以在 Profiler 的顶部栏中启用深度分析模式。这将提供更多细节,但请注意,这个过程成本高昂,会使游戏变慢,改变图表中显示的时间,使其看起来比实际时间高得多。在这里,忽略数字,根据图表查看函数占用的过程量。你需要停止,启用深度分析,然后再次播放以使其工作:

图形用户界面、文本、应用程序、聊天或文本消息描述自动生成

图 18.26:启用深度分析

带着这些知识,我们可以开始提高我们的游戏性能(如果它低于目标帧率),但每个函数都是由 CPU 调用的,并且以它独特的方式改进,这需要更多关于 Unity 内部运作的知识。这可能需要几本书来涵盖,而且无论如何,内部结构会随着版本的不同而变化。相反,你可以通过在网上查找关于该特定系统的数据以及官方文档来研究每个函数的工作方式,或者再次,通过禁用和启用我们的代码中的对象或部分来探索我们的行动的影响,就像我们在帧调试器中所做的那样。

性能分析需要创造力和推理来解释和相应地反应所获得的数据,因此你需要一些耐心。

现在我们已经讨论了如何获取与 CPU 相关的性能数据,让我们讨论一些减少CPU 使用的常见方法。

通用 CPU 优化技术

在 CPU 优化方面,有许多可能导致性能高的原因,包括滥用 Unity 的功能、大量物理或音频对象、不正确的资产/对象配置等。我们的脚本也可以以非优化的方式编写,滥用或误用昂贵的 Unity API 函数。到目前为止,我们已经讨论了使用 Unity 系统的一些良好实践,例如音频配置、纹理大小、批处理,以及找到像 GameObject.Find 这样的函数并将它们替换为管理器。那么,让我们讨论一些常见情况的具体细节。

让我们先看看大量对象是如何影响我们的性能的。在这里,您只需创建大量配置为 Dynamic ProfileRigidbody 对象(至少 200 个),并在 Profiler 中观察结果。

您会注意到,在下面的屏幕截图中,Profiler 的橙色部分变大了,而 Physics.RunSimulationStage 函数是导致这种增加的原因:

包含表格的图片 自动生成的描述

图 18.27:多个对象的物理处理

请记住,Profiler 有其他模块,您可以通过点击 Profiler 模块 按钮来激活它们,其中有一个用于物理。考虑启用它并检查它提供的信息。同时,请查看 Profiler 的官方文档以获取有关这些模块的更多信息。

另一个测试多个对象影响的方法是创建大量的音频源。在下面的屏幕截图中,您可以看到我们需要重新启用 Others,因为音频处理的一部分属于该类别。我们之前提到 Others 属于编辑器,但它也可以包含其他进程,所以请记住这一点:

图表 自动生成的描述,置信度低图 18.28:多个音频源的音频处理

因此,为了发现这类问题,您只需开始禁用和启用对象,看看它们是否增加了时间。最后的测试是对粒子进行的。创建一个生成足够数量的粒子以影响我们的帧率的系统,并检查 Profiler。

在下面的屏幕截图中,您可以检查粒子处理函数在图中的高亮显示,显示它需要大量时间:

时间轴 自动生成的描述

图 18.29:粒子处理

然后,在脚本方面,我们还有其他需要考虑的事情,其中一些是所有编程语言和平台共有的,例如迭代长列表的对象、数据结构的误用和深度递归。然而,在本节中,我主要将讨论 Unity 特定的 API,从 printDebug.Log 开始。

这个功能在控制台获取调试信息很有用,但它也可能很昂贵,因为所有日志都会立即写入磁盘,以避免我们的游戏崩溃时丢失有价值的信息。磁盘写入操作非常慢,即使使用 SSD,所以我们想尽可能避免它们。当然,我们还想保留这些有价值日志,但我们不希望它们影响性能,那么我们能做什么呢?

一种可能的方法是保留这些消息,但在最终构建中禁用非必要的消息,例如信息性消息,同时保持错误报告功能活跃。一种实现方式是通过编译器指令,如下面的屏幕截图所示。请记住,这种if语句是由编译器执行的,如果条件不满足,可以在编译时排除整个代码段:

包含文本、字体、屏幕截图、行描述的图片,描述自动生成

图 18.30:禁用代码

在前面的屏幕截图中,你可以看到我们正在询问这段代码是由编辑器编译的还是为开发构建编译的,这是一种特殊的构建,旨在用于测试(更多内容将在下一章中介绍)。你也可以使用编译器指令创建自己的日志系统,这样你就不需要在每个想要排除的日志中使用它们。

在本节中,我们学习了 CPU 在处理视频游戏时面临的任务,如何对它们进行性能分析以查看哪些任务是不必要的,以及如何减少这些过程的影响。还有一些脚本方面会影响性能,不仅是在处理方面,也在内存方面,所以让我们在下一节中讨论它们。

优化内存

我们讨论了如何对 CPU 和 GPU 这两块硬件进行性能分析和优化,但还有另一块硬件在我们的游戏中扮演着关键角色——RAM。这是我们放置所有游戏数据的地方。游戏可能是内存密集型应用程序,并且与许多其他应用程序不同,它们会持续执行代码,因此我们需要特别小心。问题是如果我们消耗过多的内存,我们可能会因为更昂贵的内存访问而降低游戏性能,甚至可能导致我们的游戏在非 PC 平台(如移动设备或甚至游戏机)上崩溃。

在本节中,我们将探讨以下内存优化概念:

  • 内存分配和垃圾回收

  • 使用内存分析器

让我们开始讨论内存分配是如何工作的,以及垃圾回收在这里扮演什么角色。

内存分配和垃圾回收

每次我们实例化一个对象时,我们都会在 RAM 中分配内存,在游戏中,我们将不断地分配内存。在其他编程语言中,除了分配内存外,您还需要手动释放它,但 C#有一个垃圾回收器,这是一个跟踪未使用内存并清理它的系统。这个系统使用引用计数器,它跟踪一个对象存在的引用数量,当这个计数器达到0时,这意味着所有引用都变为 null,对象可以被释放。这个释放过程可以在几种情况下触发,最常见的情况是我们达到最大分配内存并想要分配一个新对象时。在这种情况下,我们可以释放足够的内存来分配我们的对象,如果这不可能,内存将被扩展。

在任何游戏中,你可能会不断地分配和释放内存,这可能导致内存碎片化,意味着在活动对象内存块之间存在小空间,这些空间大部分是无用的,因为它们不够大,无法分配对象,或者这些空间的总和可能足够大,但我们需要连续的内存空间来分配我们的对象。

在下面的图中,你可以看到一个经典的例子,即试图将一大块内存放入由碎片化产生的细小缝隙中:

图形用户界面  描述由低置信度自动生成

图 18.31:试图在碎片化内存空间中实例化一个对象

一些垃圾回收系统类型,如常规 C#中的系统,是分代的,这意味着内存根据其“年龄”被分成代桶。较新的内存将被放置在第一个桶中,这种内存倾向于频繁地分配和释放。因为这个桶很小,所以在其中工作很快。第二个桶包含在第一个桶中经过先前释放扫描过程的内存。这种内存被移动到第二个桶中,以防止它被不断检查是否通过了这个过程,并且这种内存可能持续我们程序的生命周期。第三个桶只是第二个桶的另一层。想法是,大多数时候,分配和释放系统将在第一个桶中工作,因为它足够小,所以快速分配、释放和连续压缩内存。

有关 Unity 如何管理内存以及内存碎片化的更详细信息,请参阅以下链接:docs.unity3d.com/Manual/performance-managed-memory.html

这里的问题是 Unity 使用它自己的垃圾回收系统版本,而这个版本是非代际和非压缩的,这意味着内存不会被分成桶,内存也不会被移动来填补空隙。这表明在 Unity 中分配和释放内存仍然会导致碎片化问题,如果你不管理你的内存分配,你可能会频繁执行昂贵的垃圾回收系统,这会在我们的游戏中产生中断,你可以在 Profiler CPU Usage 模块中看到,以浅黄色表示。

处理这个问题的一种方法是在尽可能的情况下防止内存分配,在不必要的时候避免它。这里有一些小调整可以防止内存分配,但在查看这些调整之前,再次强调,在开始修复可能不是问题的东西之前,首先了解问题的数据是非常重要的。这条建议适用于任何类型的优化过程。在这里,我们仍然可以使用 CPU 使用率 分析器来查看 CPU 在每一帧中执行每个函数调用时分配了多少内存,这很简单,只需查看 GC Alloc 列,它表示函数分配的内存量:

表格描述自动生成,置信度中等

图 18.32:Sight 更新事件函数的内存分配

在前面的屏幕截图中,我们可以看到我们的函数分配了过多的内存,这是由于场景中有许多敌人造成的。但这并不是借口;我们每帧都在分配这么多的 RAM,因此我们需要改进这一点。有几件事情可能导致我们的内存被分配所占用,让我们先从返回数组的函数开始讨论基本问题。

如果我们回顾一下 Sight 脚本代码,我们可以看到我们分配内存的唯一时刻是在调用 Physics.OverlapSphere 时,这一点很明显,因为它是一个返回数组的函数,这类函数会返回不同数量的数据。为了做到这一点,它需要分配一个数组并将该数组返回给我们。这需要在创建函数的那一侧,即 Unity 上进行,但在这个案例中,Unity 给我们提供了两个版本的函数——我们正在使用的版本和 NonAlloc 版本。通常建议使用第二个版本,但 Unity 使用另一个版本来简化初学者的编码。

NonAlloc 版本如下截图所示:

计算机代码屏幕截图,描述自动生成,置信度低图 18.33:Sight 更新事件函数的内存分配

这个版本要求我们分配一个足够大的数组来保存OverlapSphere变量能找到的最大数量的碰撞器,并将其作为第三个参数传递。这允许我们只分配一次数组,并在需要时重复使用它。在前面的截图中,你可以看到数组是静态的,这意味着它在所有Sight变量之间共享,因为它们不会并行执行(没有Update函数)。这将工作得很好。请记住,该函数将返回检测到的对象数量,所以我们只需迭代这个计数。数组可以存储之前的结果。

现在,检查你的分析器并注意分配的内存量已经大大减少。在我们的函数中可能还有一些剩余的内存分配,但有时无法将其保持在0。然而,你可以尝试使用深度分析或通过注释一些代码来查看哪个注释消除了分配。我挑战你尝试这个,并观察哪些变化导致内存分配值减少。此外,OverlapSphere并不是唯一可能发生这种情况的案例。你还有其他,例如GetComponents函数家族,与GetComponent不同,它找到给定类型的所有组件,而不仅仅是第一个组件,所以请注意 Unity 中任何返回数组的函数,并尝试用非分配版本替换它,如果有的话。

另一个常见的内存分配来源是字符串连接。记住,字符串是不可变的,这意味着如果你连接两个字符串,它们不能改变。第三个需要生成足够的空格来容纳前一个字符串。如果你需要多次连接,如果你只是在一个模板字符串中替换占位符,例如在消息中放入玩家的名字和得分,考虑使用string.Format,或者使用StringBuilder类,它只是将所有要连接的字符串放在一个列表中,并在必要时将它们连接在一起,而不是像+运算符那样逐个连接。此外,考虑使用 C#的新字符串插值功能。你可以在以下截图中看到一些示例:

计算机代码的截图  描述由低置信度自动生成

图 18.34:C#中的字符串管理

最后,一个值得考虑的经典技术是对象池,适用于需要不断实例化和销毁对象的情况,例如子弹或效果。在这种情况下,使用常规的InstantiateDestroy函数会导致内存碎片化,但对象池通过分配尽可能多的所需对象来解决这个问题。它通过从预分配的函数中取一个来替换Instantiate,并通过将对象返回到池中来替换Destroy

以下截图显示了简单的池:

计算机程序截图,描述由中等置信度自动生成

图 18.35:一个简单的对象池

有几种方法可以改进这个池,但就目前而言,它已经足够好了。请注意,当对象从池中取出时,需要重新初始化它们,您可以使用 OnEnable 事件函数或创建一个自定义函数来通知对象这样做。

请注意,Unity 最近添加了一个 Object Pool 类,您可以在以下链接中调查:docs.unity3d.com/2023.1/Documentation/ScriptReference/Pool.ObjectPool_1.html,但我仍然建议您先自己创建一个,以掌握池的概念。

现在我们已经探索了一些基本的内存分配减少技术,让我们看看 Unity 之前版本中引入的新的内存分析器工具,现在它不再是预览包,而是作为 1.0.0 版本提供,以更详细地探索内存。

使用内存分析器

使用这个分析器,我们可以按帧检测分配的内存,但它不会显示到目前为止分配的总内存,这对于研究我们如何使用内存非常有用。这就是内存分析器能帮到我们的地方。这个相对较新的 Unity 包允许我们在原生和托管方面对每个分配的对象进行内存快照——原生意味着内部 C++ Unity 代码,托管意味着属于 C# 方面的任何内容(即,我们的代码和 Unity 的 C# 引擎代码)。我们可以使用可视化工具探索快照,并快速查看哪种类型的对象消耗了最多的 RAM 以及它们是如何被其他对象引用的。

要开始使用内存分析器,请执行以下操作:

  1. 打开包管理器窗口 | 包管理器)并查找内存分析器包。在撰写本书时,您可以看到可用的最新稳定版本是 1.1.0:

图片 B21361_18_36.png

图 18.36:启用预览包

  1. 安装完成后,在窗口 | 分析 | 内存分析器中打开内存分析器。

  2. 播放游戏并点击内存分析器窗口中的捕获按钮:

图片 B21361_18_37.png

图 18.37:捕获快照

  1. 点击列表中出现的快照(位于Session 1标签下方)以查看快照时刻的内存消耗摘要:

图片 B21361_18_38_.png

图 18.38:内存摘要

  1. 在我们的案例中,我们可以看到我们消耗了 2.76 GB 的内存,分为托管(C#代码变量)、可执行文件和映射(应用程序的构建代码)、原生(Unity 的 C++内存)、图形(图形驱动程序和 GPU 内存使用以渲染我们的场景)、分析器(因为我们正在分析我们的会话)、音频未知(无法分类的内存,如第三方原生插件所做的分配)。这些类别中有不同的东西需要考虑,但就目前而言,我们已经足够好了。在包管理器中打开包文档以获取更多关于它们的信息。

  2. 点击内存分析器窗口中间部分顶部部分的Unity 对象选项卡。这将打开Unity 对象视图,允许您以表格格式直观地看到哪些类型的资产在内存方面要求更高:

图 18.39:内存树视图

  1. 在我们的案例中,我们可以看到RenderTexture消耗了最多的内存,这属于场景中显示的图像,以及一些用于后期处理效果的纹理。尝试禁用PPVolume对象并再次拍摄快照以检测差异。

  2. 在我的情况下,这减少了 56 MB。还有其他用于其他效果的纹理,例如 HDR。如果您想探索剩余的 MB 来自哪里,请点击RenderTexture左侧的箭头以查看其对象列表,并根据纹理名称进行自己的猜测:

图 18.40:内存块详细情况

  1. 您可以在Texture2D列表中重复相同的过程,这属于我们模型材料中使用的纹理。您可以查看最大的一个并检测其使用情况——可能是一个大纹理,从未被足够接近地看到,以证明其大小合理。然后,我们可以使用纹理最大尺寸导入设置来减小其大小。

  2. 您还可以使用Unity 对象视图底部的两个复选框,将层次结构中出现的所有对象(没有任何组或类别)展平,以查找任何可能的重复对象(即,在项目中错误地重复相同的资产)。

图 18.41:过滤 Unity 对象组的显示

就像任何分析器一样,始终在构建过程中进行性能分析非常有用(关于这一点将在下一章中详细介绍),因为在编辑器中拍摄快照将捕获大量编辑器使用的内存,这些内存在构建中不会被使用。一个例子是加载不必要的纹理,因为编辑器可能是在您点击它们以在检查器窗口中查看预览时加载它们的。

考虑到内存分析器是一个包,其 UI 可能会经常变化,但其基本理念将保持不变。您可以使用此工具检测您是否以意外的方式使用内存。这里值得考虑的一个有用因素是 Unity 在加载场景时如何加载资源,这包括在加载时加载场景中引用的所有资源。这意味着您可以有,例如,一个预制体数组,这些预制体引用了具有引用纹理的材料,即使您没有实例化它们的单个实例,预制体也必须在内存中加载,从而占用空间。在这种情况下,我建议您探索使用Addressables,它提供了一种动态加载资源的方法。但现在让我们保持简单。

内存分析器还具备比较两个快照的功能。这对于检测游戏中两个特定时刻(例如,在开始关卡之前和开始下一个关卡之后)之间的内存泄漏(在 C#中,这可能是由于未取消引用的引用引起的)和未释放的资源非常有用。有关更多详细信息,请参阅此文档:docs.unity3d.com/Packages/com.unity.memoryprofiler@1.0/manual/snapshots-comparison.html

摘要

优化游戏并非易事,尤其是如果你不熟悉每个 Unity 系统的工作原理。遗憾的是,这是一个巨大的任务,没有人知道每个系统最细微的细节,但通过本章中学习到的工具,我们可以探索如何通过探索来了解变化对系统的影响。我们学习了如何分析 CPU、GPU 和 RAM,以及任何游戏中关键硬件的内容。我们还介绍了一些常见的良好实践,以避免滥用它们。

现在,您可以在游戏中诊断性能问题,收集关于 CPU、GPU 和 RAM 这三件主要硬件性能的数据,然后使用这些数据将您的优化努力集中在应用正确的优化技术。性能很重要,因为您的游戏需要运行流畅,以给用户带来愉快的体验。

在下一章中,我们将了解如何创建一个无需安装 Unity 即可与他人分享的游戏构建版本。这对于分析也非常有用,因为分析构建将比在编辑器中进行分析提供更准确的数据。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家和作者本人一起阅读此书。提出问题,为其他读者提供解决方案,通过“问我任何问题”会议与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第十九章:从原型到可执行文件:生成和调试你的游戏

因此,我们已经达到了一个阶段,游戏的状态足够好,可以用来测试真实用户,这是一个理论设计遇到实际应用的时刻。问题是,我们不能假装人们会安装 Unity,打开一个项目,然后点击Play。他们希望得到一个可以直接双击并立即播放的漂亮的可执行文件。在本章的第一节中,我们将讨论如何将我们的项目转换成一个易于分享的可执行格式,然后在第二节中,我们将看到如何应用上一章学到的性能分析和调试技术,但这次是在构建过程中。阅读完本章后,你将能够检测潜在的性能瓶颈并解决最常见的瓶颈,从而提高你游戏的帧率。

在本章中,我们将探讨以下构建概念:

  • 构建项目

  • 构建调试

让我们先看看如何构建项目以获得一个可分享的可执行文件。

构建项目

在软件开发(包括视频游戏)中,将我们项目的源文件转换为可执行格式的过程被称为构建。生成的可执行文件针对项目的配置进行了优化,以实现可能的最大性能。由于项目的不断变化,我们在编辑游戏时无法判断性能。在编辑游戏的同时准备最终形式的资产将非常耗时。

此外,生成的文件格式难以阅读。它们不会包含纹理、音频和源代码文件,供用户查看。它们将以自定义的文件结构格式化,因此从某种意义上说,它们被保护免受用户窃取。

实际上,有几个工具可以从视频游戏中提取源文件,尤其是从广泛使用的引擎,如 Unity 中提取。你可以提取如纹理和 3D 模型这样的资产,甚至有程序可以直接从 VRAM 中提取这些资产,所以我们不能保证这些资产不会被用于游戏之外。最终,用户将拥有这些资产的磁盘数据。

当你针对桌面平台,如 Windows、Mac 或 Linux 时,构建过程相当简单,但在构建之前,我们需要注意一些设置。我们将首先看到的第一项配置是场景列表。我们已经讨论过这个问题,但这是一个很好的时刻来记住,设置列表的第一个元素为第一个要加载的场景是很重要的。记住,你可以通过转到文件 | 构建设置并将你希望作为启动场景的所需场景拖到列表顶部来完成此操作。在我们的例子中,我们将游戏场景定义为第一个场景,但在实际游戏中,使用 UI 和一些图形创建一个主菜单场景将是理想的:

包含文本、字体、截图、白色的图片,描述自动生成

图 19.1:构建列表中的场景顺序

您还可以在此处更改的另一个设置是目标平台,即构建将为其创建的目标操作系统。通常,这设置为与您正在开发的相同操作系统,但如果您,例如,在 Mac 上开发,并希望为 Windows 构建,只需将目标平台设置更改为Windows。这样,结果将是一个.exe文件(Windows 可执行文件)而不是.app文件(Mac 可执行文件)。您可能还会看到 Android 和 iOS 作为其他目标平台,但制作移动游戏需要考虑其他因素,这些因素我们将在本书中不讨论:

计算机截图,描述自动生成,中等置信度

图 19.2:目标平台

在同一窗口中,您可以点击左下角的玩家设置按钮,或者直接打开编辑 | 项目设置窗口,点击玩家类别以访问其余的构建设置。Unity 将生成的可执行文件称为Player。在此处,我们有一组配置,将影响构建或玩家的行为,以下是基本配置列表:

  • 公司名称:这是开发游戏的公司的名称,Unity 将使用它来创建某些文件路径,并将包含在可执行文件信息中。

  • 产品名称:这是窗口标题栏和可执行文件中的游戏名称。

  • 默认图标:在此处,您可以选择一个纹理作为可执行文件图标。

  • 默认光标:您可以设置一个纹理来替换常规系统光标。如果您这样做,请记住将光标热点属性设置为图像中您想要光标点击的像素。

  • 分辨率和显示设置:这些设置决定了我们的游戏分辨率如何处理。

  • 分辨率和显示设置 | 全屏模式:您可以选择游戏是启动为窗口模式还是不同的全屏模式。如果需要,您可以通过脚本更改此设置。

  • 分辨率和显示设置 | 默认为原生分辨率:当此选项被勾选且全屏模式设置为使用任何全屏选项时,系统当前使用的分辨率将被 Unity 使用。您可以取消勾选此选项并设置您想要的分辨率。

  • 启动画面:这些是游戏首次加载后显示的启动画面的设置。

  • 启动画面 | 显示启动画面:这将启用 Unity 启动画面,显示标志作为游戏的介绍。如果您拥有 Unity Pro 许可证,您可以取消勾选此选项以创建自定义的启动画面。此外,当 Unity 6 发布时,即使使用免费许可证,也可以禁用启动画面。

  • 启动画面 | 标志列表:在这里,您可以添加一组 Unity 在启动游戏时将显示的图片。如果您使用的是 Unity 的免费版本,您将被迫在这个列表中显示 Unity 标志。

  • 启动画面 | 绘制模式:您可以将其设置为全部顺序以逐个显示每个标志,或者设置为Unity 标志在下方以显示您的自定义介绍标志,而 Unity 标志始终位于您的标志下方:

计算机截图  描述由中等置信度自动生成

图 19.3:玩家设置

有关玩家设置的更多信息,请查看以下链接:docs.unity3d.com/2023.1/Documentation/Manual/class-PlayerSettings.html

在您希望配置这些设置后,下一步是进行实际构建,这可以通过在文件 | 构建设置窗口中点击构建按钮来完成。这将要求您设置构建文件要创建的位置。我建议您在桌面上创建一个空文件夹,以便轻松访问结果。请耐心等待——这个过程可能需要一段时间,具体取决于项目的大小:

计算机截图  描述由中等置信度自动生成

图 19.4:构建游戏

这里可能会失败的是存在不兼容构建的脚本——这些脚本仅打算在编辑器中执行(主要是编辑器扩展)。我们还没有创建任何这样的脚本,所以如果您在构建后控制台中有错误消息,类似于以下截图,那可能是因为 Asset Store 包中的某个脚本。在这种情况下,只需删除在构建错误消息之前控制台中显示的文件。如果其中包含您的脚本,请确保您的任何脚本中都没有using UnityEditor;行。

这将尝试使用编辑器命名空间,即不包括在构建编译中以便在磁盘上节省空间的命名空间:

计算机截图  描述由中等置信度自动生成

图 19.5:构建错误

为了配置构建,你需要知道的最少信息就是这些。你已经生成了你的游戏!记得在不同设备上测试你的构建,尤其是在移动平台开发时;有时游戏在不同平台上可能会有不同的表现,甚至在某些硬件上由于不兼容而完全无法运行。这些不兼容性可能高度依赖于特定硬件,并且必须逐个案例进行研究。需要注意的是,在构建时你指定的文件夹中创建的每个文件都必须共享,而不仅仅是可执行文件。数据文件夹包含所有资源,在 Windows 构建共享游戏时非常重要。对于 Linux 和 Mac 构建,只生成一个文件(Linux 为x86/x86_64,Mac 为app packages):

包含文本、字体、屏幕截图的图片,描述自动生成

图 19.6:Windows 生成的文件夹

现在我们有了构建,你可以通过双击可执行文件来测试它。我们现在可以讨论如何使用在编辑器中使用的相同的调试和性能分析工具来调试我们的构建。

调试构建

在理想的世界里,编辑器和构建的行为应该是相同的,但遗憾的是,事实并非如此。编辑器已经准备好在快速迭代模式下工作。代码和资源在使用前进行最小处理,以便频繁快速地做出更改,这样我们就可以轻松测试我们的游戏。当游戏构建时,会应用一系列优化和与编辑器项目的差异,以确保我们能够获得最佳性能,但这些差异可能会导致游戏某些部分的行为不同,使得玩家的性能数据与编辑器不同。这就是为什么我们要探讨如何调试和性能分析我们所构建的游戏。

在本节中,我们将探讨以下构建调试概念:

  • 调试代码

  • 性能分析

让我们先讨论如何调试构建的代码。

调试代码

由于玩家代码的编译方式不同,构建中可能会出现编辑器中没有出现的错误,我们需要以某种方式调试它。我们有两种主要的调试方式——通过打印消息和通过断点。所以,让我们从第一种方式开始,消息。如果你运行了你的可执行文件,你可能已经注意到没有可用的控制台。它只是全屏的游戏视图,这是有道理的;我们不希望用令人烦恼的测试消息分散用户的注意力。幸运的是,消息仍然被打印出来,但它们在一个文件中,所以我们可以直接去那个文件查找它们。

位置根据操作系统而变化。在这个列表中,你可以找到可能的地址:

  • Linux: ~/.config/unity3d/CompanyName/ProductName/Player.log

  • Mac: ~/Library/Logs/Company Name/Product Name/Player.log

  • Windows: C:\Users\username\AppData\LocalLow\CompanyName\ProductName\Player.log

在这些路径中,你必须将 CompanyNameProductName 替换为我们之前在 Player 设置中设置的属性的值,这些属性分别称为 公司名称产品名称。在 Windows 中,你必须将 username 替换为你正在执行游戏的 Windows 账户名称。请注意,文件夹可能被隐藏,所以请启用操作系统中的显示隐藏文件选项。在那个文件夹中,你会找到一个名为 Player 的文件;你可以用任何文本编辑器打开它并查看消息。

除了从 Asset Store 下载任何自定义包之外,还有一种方法可以直接在游戏中查看控制台消息,至少是错误信息:通过创建开发构建。这是一种特殊的构建,它允许在交换不进行最终构建那样的完全优化代码的情况下,提供扩展的调试和性能分析功能,但这对一般调试来说已经足够了。

你可以通过在 文件 | 构建设置 窗口中勾选 开发构建 复选框来创建这种类型的构建:

带有中等置信度的计算机截图

图 19.7:开发构建复选框

记住,这里只显示错误信息,所以你可以使用的一个小技巧是将 printDebug.Log 函数调用替换为 Debug.LogError,这样也会在控制台打印消息,但会显示红色图标。请注意,使用 Debug.LogError 来显示非错误信息并不是一个好的做法,所以请限制这类消息的使用,仅用于临时调试。对于永久记录,请使用日志文件或在 Asset Store 中寻找运行时的自定义调试控制台。

包含文本、截图、字体、行描述自动生成的图片

图 19.8:调试错误信息

关于开发构建的一个有趣之处是,与常规构建不同,错误信息会直接显示在构建中,允许你正确地调试你的项目。请记住,由于这些功能,开发构建可能会更慢,因为它们旨在用于调试并允许性能分析器工作,但最终发布构建的性能会更好。在下一张截图中,你可以看到运行时显示的错误:

包含截图、设计描述自动生成的图片

图 19.9:开发构建中的错误信息

你会注意到,除了显示错误信息之外,在右侧还有一个 打开日志文件 按钮,允许你查看日志文件。这是一个文本文件,包含有关游戏中发生的所有消息和日志的详细信息,以便定位问题。本质上,这是与编辑器中 控制台 面板显示的相同信息。

请记住,为了使开发构建工作,你需要再次构建游戏;幸运的是,第一次构建是最耗时的,接下来的构建会更快。这次,你只需点击构建并运行按钮,在之前构建的文件夹中进行构建。

此外,你还可以像我们在第五章释放 C#和视觉脚本的力量中解释的那样,使用常规断点。通过将 IDE 连接到播放器,它将出现在目标列表中。但为了使其工作,你必须在构建窗口中不仅勾选开发构建,还要勾选脚本调试

在这里,当勾选时,你会看到一个额外的选项,允许你暂停整个游戏,直到附加调试器,这个选项叫做等待托管调试器。当你想要测试一开始立即发生的事情,但给你附调试器的时间不够时,这很有用:

包含文本、截图、字体、数字的图片,自动生成描述

图 19.10:启用脚本调试

我们还有另一种查看消息的方法,但这需要性能分析器工作,所以让我们以此为借口也讨论一下如何对编辑器进行性能分析。

性能分析

我们将使用与上一章中看到相同的工具,但这次是为了分析播放器。幸运的是,差异很小。正如我们在上一节中所做的那样,你需要以开发模式构建播放器,在构建窗口中勾选开发构建复选框,然后性能分析器应该会自动检测到它。

让我们从以下步骤开始使用性能分析器对构建进行分析:

  1. 通过构建运行游戏。

  2. 使用Alt + Tab(Mac 上的Cmd + Tab)切换到 Unity。

  3. 打开性能分析器。

  4. 点击名为播放模式的菜单,并选择包含播放器的项。因为我使用了 Mac,它说OSXPlayer;并且名称将根据构建平台而变化(例如,Windows 构建将显示为WindowsPlayer):

计算机截图,中等置信度自动生成描述

图 19.11:分析播放器

注意,当你点击一个帧时,游戏不会像在编辑器中那样停止。如果你想专注于特定时刻的帧,你可以点击记录按钮(红色圆圈),这样性能分析器就会停止捕获数据,你可以分析到目前为止捕获的帧。

此外,你可以看到当性能分析器附加到播放器时,控制台也会附加,因此你可以在 Unity 中直接查看日志。请注意,这个版本需要打开 Unity,我们无法期望测试我们游戏的朋友也有它。你可能需要点击控制台上出现的播放器按钮,并勾选播放器日志才能使其工作:

计算机的截图  自动生成的描述

图 19.12:在附加 Profiler 后启用玩家日志记录

帧调试器也已被启用以与玩家协同工作。您需要在帧调试器中点击编辑器按钮,然后再次点击,您将在可能的调试目标列表中看到玩家;选择它后,按常规点击启用。请注意,绘制调用的预览将不会在游戏视图中显示,而是在构建本身中显示。如果您正在全屏模式下运行游戏,您可能需要在 Unity 和构建之间来回切换:

计算机的截图  使用中等置信度自动生成的描述

图 19.13:调试我们游戏玩家的帧

您还可以以窗口模式运行游戏,将玩家设置中的全屏模式属性设置为窗口模式,并设置一个小于您桌面分辨率的默认分辨率。这允许 Unity 和玩家同时可见,以便同时看到分析器和游戏:

包含文本、字体、数字、行的图片  自动生成的描述

图 19.14:启用窗口模式

最后,内存分析器也支持分析玩家,正如您所猜测的,您只需在点击窗口顶部栏中的编辑器按钮时显示的列表中选择玩家,然后点击捕获

计算机的截图  自动生成的描述

图 19.15:获取玩家的内存快照

就这样。如您所见,Unity 分析器被设计成易于与玩家集成。如果您开始从它们那里获取数据,您将看到与编辑器分析相比的差异,尤其是在内存分析器中。

摘要

在本章中,我们学习了如何创建游戏的可执行版本,并正确配置它,以便我们可以与我们的朋友以及可能的世界分享!我们还讨论了如何分析我们的构建;请记住,这样做将比分析编辑器提供更准确的数据,因此我们可以更好地提高我们游戏的表现力。

现在我们已经完成了我们的游戏,让我们通过在下一章中探索 AR Foundation 包来一窥您的下一个项目如何轻松地成为一个 Unity 增强现实应用程序。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家和作者本人一起阅读此书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会议与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码  图片

第二十章:AR/VR

Unity 已经从游戏扩展到各个领域,如模拟、培训和应用程序开发。在其最新版本中,引入了显著的增强现实(AR)技术改进。在 Unity 的最新版本中,我们在增强现实领域看到了许多改进,这使得我们能够在我们的现实之上添加一层虚拟性,从而增强我们的设备所能感知的内容,以创建依赖于现实世界数据的游戏,例如摄像机的图像、我们的现实世界位置和当前的天气。这也可以应用于工作环境,例如查看建筑图或检查墙内的电线管道。欢迎来到本书的额外部分,我们将讨论如何使用 Unity 的 AR Foundation 包创建增强现实AR)应用程序。

在本章中,我们将探讨以下 AR Foundation 概念:

  • 使用 AR Foundation

  • 为移动设备构建

  • 创建一个简单的 AR 游戏

到本章结束时,你将能够使用 AR Foundation 创建 AR 应用程序,并将拥有一个使用其框架的完整功能游戏,以便你可以测试框架的功能。

让我们先探索 AR Foundation 框架。

使用 AR Foundation

在本节中,我们将探讨以下 AR Foundation 概念:

  • 创建 AR Foundation 项目

  • 使用跟踪功能

让我们先讨论如何准备我们的项目,以便它可以运行 AR Foundation 应用程序。

创建 AR Foundation 项目

转向 AR 开发不仅改变了编码实践,还改变了游戏设计方法。例如,在 AR 中,用户交互和相机控制有显著的不同。

在创建 AR 项目时需要考虑的是,我们将改变不仅是我们编写游戏的方式,还有游戏设计方面。AR 应用程序有差异,尤其是在用户交互的方式上,以及限制,例如用户始终控制相机。我们不能简单地将现有游戏移植到 AR,而不改变游戏的核心体验。这就是为什么在本章中,我们将着手一个全新的项目;改变我们迄今为止创建的游戏以使其在 AR 中运行将非常困难。

在我们的案例中,我们将创建一个游戏,用户控制一个移动“标记”的玩家,这是一个你可以打印的物理图像,它将允许我们的应用程序识别玩家在现实世界中的位置。我们可以在移动该图像的同时移动玩家,这个虚拟玩家将自动射击最近的敌人。这些敌人将从用户需要在房屋不同部分放置的特定出生点生成。例如,我们可以在墙上放置两个出生点,并将我们的玩家标记放在房间中间的桌子上,这样敌人就会朝它们走去。在下面的图像中,你可以看到游戏的预览:

桌子上的一台电脑  描述由低置信度自动生成

图 20.1:完成的游戏。圆柱体是敌人生成器,胶囊体是敌人,立方体是玩家。这些都在手机显示的标记图像中定位

我们将以创建我们之前游戏相同的方式开始创建一个新的基于 URP 的项目。需要考虑的是,AR Foundation 可以与其他管道一起工作,包括内置的管道,如果您想在现有项目中使用它。如果您不记得如何创建项目或使用包管理器,请参阅第一章踏上您的 Unity 之旅

一旦您进入您的新空白项目,就像我们之前安装其他包一样,从包管理器安装 AR Foundation 包——即从窗口 | 包管理器

请记住设置包管理器,使其显示所有包,而不仅仅是项目中的包(窗口左上角的按钮需要设置为Unity 注册表)以及预览版本(点击轮形图标,然后项目设置,并在出现的窗口上检查启用预发布包)。

在撰写本书时,最新稳定版本是 5.1.0 版本。虽然我们在这个例子中使用 AR Foundation 的 5.1.0 版本,但始终检查您项目中最新的稳定版本。

如同往常,如果出现提示您启用新输入系统的警告,请点击

图 20.2:安装 AR Foundation

在我们安装任何其他需要的包之前,现在是讨论 AR Foundation 框架核心思想的好时机。这个包本身没有任何作用;它定义了一系列移动设备提供的 AR 功能,如图像跟踪、云点和对象跟踪,但实际如何实现这些功能的实现包含在Provider包中,例如Apple ARKit XR 插件Google ARCore XR 插件包。这样设计是因为,根据您想要与之合作的目标设备,实现这些功能的方式会改变。例如,在 iOS 中,Unity 使用 ARKit 实现这些功能,而在 Android 中,它使用 ARCore;它们是平台特定的框架。请记住安装与 AR Foundation 相同版本的这些平台包(在本例中为 5.0.0 预览版 13)。

在这里需要考虑的是,并非所有 iOS 或 Android 设备都支持 AR Foundation 应用。您在网上搜索 ARCore 和 ARKit 支持的设备时,可能会找到一个更新的支持设备列表。在撰写本书时,以下链接提供了支持设备列表:

此外,目前还没有 PC 提供程序包,因此迄今为止测试 AR Foundation 应用程序的唯一方法是直接在设备上,但测试工具很快就会发布。在我的情况下,我将为 iOS 创建一个应用程序,因此除了AR Foundation程序包之外,我还需要安装ARKit XR插件。

然而,如果你想要为 Android 开发,请安装ARCore XR插件(或者如果你针对两个平台,则两者都安装)。此外,我将使用这些包的 5.1.1 版本。通常,AR FoundationProvider包的版本是一致的,但应用相同的逻辑,就像你选择AR Foundation版本时一样。

现在我们有了所需的插件,我们需要为 AR 准备一个场景,如下所示:

  1. 文件 | 新场景中创建一个新的场景,并选择基本(URP)模板。

  2. 删除主摄像头;我们将使用不同的一个。

  3. GameObject | XR菜单中,创建一个AR 会话GameObject。

  4. 在相同的菜单中,创建一个包含摄像头XR 原点(移动 AR)对象。

  5. XR 原点内部选择主摄像头

  6. AR 摄像头管理器组件的渲染模式属性设置为在不透明物体之后

  7. 您的层次结构应该如下所示:

计算机屏幕截图  描述由中等置信度自动生成

图 20.3:AR 入门场景

AR 会话对象将负责初始化 AR 框架,并将处理 AR 系统的所有更新逻辑。XR 原点对象将允许框架定位跟踪对象,如图像和点云,相对于场景的相对位置。设备会告知跟踪对象相对于设备认为的“原点”的位置。这通常是在应用程序开始检测对象时你指向的第一个区域,因此XR 原点对象将代表你物理空间中的那个点。最后,你可以检查原点内的摄像头,它包含一些额外的组件,其中最重要的是跟踪姿态驱动器,它将使你的摄像头对象随着设备移动。由于设备的位姿是相对于会话原点对象点的,因此摄像头需要位于原点对象内部。

如果你在进行 URP 项目(我们的情况),还需要额外的一步,即你需要设置渲染管线,以便它支持在应用程序中渲染摄像头图像。为此,前往我们创建项目时生成的Settings文件夹,查找URP-HighFidelity-Renderer文件,并选择它。在渲染器功能列表中,点击添加渲染器功能按钮并选择AR 背景渲染器功能。在下面的屏幕截图中,你可以看到前向渲染器资产应该看起来是什么样子:

计算机屏幕截图  描述由低置信度自动生成

图 20.4:为 URP 添加支持

就这样!我们准备好开始探索 AR Foundation 组件,以便我们可以实现跟踪功能。

使用跟踪功能

对于我们的项目,我们需要两个最常用的 AR 跟踪功能(但不是唯一的):图像识别和平面检测。第一个功能是检测特定图像在现实世界中的位置,以便我们可以在其上方放置数字对象,例如玩家。第二个功能,平面检测,包括识别现实生活中的表面,例如地板、桌子和墙壁,以便我们有放置对象(例如敌人的出生点)的参考。只有水平和垂直表面被识别(某些设备上仅识别垂直表面)。

我们首先需要做的是告诉我们的应用程序它需要检测哪些图片,如下所示:

  1. 将一张可以打印或显示在移动设备上的图片添加到项目中。在现实世界中显示图片的方式对于测试这是必要的。在这种情况下,我将使用以下图片:包含砾石、鹅卵石、岩石、碎石的图片  自动生成的描述

    图 20.5:要跟踪的图片

    尽量获取包含尽可能多特征的图片。这意味着一张有很多小细节的图片,例如对比度、尖锐的角落等。这些都是我们的 AR 系统用来检测它的;细节越多,识别效果越好。如果你的设备在检测我们当前的图片时遇到困难,请尝试其他图片(经典的 QR 码可能有所帮助)。

  2. 考虑到某些设备可能对某些图片有困难,例如这本书中建议的图片。如果在测试时产生问题,请尝试使用另一张图片。你将在本章接下来的部分中在自己的设备上测试这个功能,所以请记住这一点。

  3. 通过在 项目面板 中点击 + 按钮,并选择 XR | Reference Image Library 来创建一个 Reference Image Library,这是一个包含我们希望应用程序识别的所有图片的资产:

图 20.6:创建参考图像库

  1. 选择我们创建的 Reference Image Library 资产,并点击 添加图片 按钮将新图片添加到库中。

  2. 将纹理拖动到纹理槽中(标有 None 的那个)。

  3. 打开 指定大小 并将 物理大小 设置为图片在现实生活中的打印尺寸,单位为米。在这里尽量准确;在某些设备上,如果没有这个值,可能会导致图片无法被跟踪:

计算机的截图  使用低置信度自动生成的描述

图 20.7:添加要识别的图片

现在我们已经指定了要检测的图片,让我们通过在现实生活中的图片上放置一个立方体来测试这个功能:

  1. 创建一个立方体的 Prefab 并将其 AR Tracked Image 组件添加到其中。

  2. 请记住,在 x 轴、y 轴和 z 轴上设置一个小的比例,例如0.1,因为默认的立方体将是 1 米乘 1 米,这在增强现实(AR)中会显得非常大。

  3. AR 跟踪图像管理器组件添加到XR 起源对象中。这将负责检测图像并在其位置创建对象。

  4. 将之前步骤中创建的图像库资产拖放到组件的序列化库属性中,以指定要识别的图像。

  5. 立方体Prefab 拖放到组件的跟踪图像 Prefab属性中:

电脑的截图  描述由中等置信度自动生成

图 20.8:设置跟踪图像管理器

就这样!稍后,在本章的为移动设备构建部分,当我们创建 iOS 或 Android 版本时,我们将看到在现实世界中图像所在的位置生成一个立方体。请记住,您需要在设备上测试此功能,我们将在下一节中这样做,所以现在让我们继续编写我们的测试应用程序:

桌子上的一台电脑  描述由低置信度自动生成

图 20.9:位于手机显示图像顶部的立方体

让我们也准备我们的应用程序,以便它可以检测并显示相机识别的平面表面。这很简单,只需将AR 平面管理器组件添加到XR 起源对象中即可。

图 20.10:添加 AR 平面管理器组件

当我们移动相机覆盖它时,该组件将检测我们房子上的表面平面。检测它们可能需要一段时间,因此可视化检测区域以获取有关此操作的反馈非常重要,以确保其正常工作。我们可以从 AR 平面管理器的组件引用手动获取有关平面的信息,但幸运的是,Unity 允许我们轻松地可视化平面。让我们看看:

  1. 首先通过在GameObject | 3D Object | Plane中创建平面来创建一个平面的 Prefab。

  2. 向其添加一个线渲染器。这将允许我们在检测区域的边缘绘制线条。

    关于线渲染器的更多信息,请查看此链接:docs.unity3d.com/Manual/class-LineRenderer.html

  3. Line Renderer宽度属性设置为一个小值,例如0.01,将颜色渐变属性设置为黑色,并取消选中使用世界空间

图 20.11:设置线渲染器

  1. 请记住,创建一个具有适当着色器(通用渲染管线/无光照)的材质,并将其设置为线渲染器组件下材料列表属性的材料:

图 20.12:创建线渲染器材质

  1. 此外,创建一个透明材质,并在MeshRenderer平面中使用它。我们希望能够透过它,这样我们就可以轻松地看到下面的真实表面:

图 20.13:检测平面的材质

  1. AR PlaneAR Plane Mesh Visualizer 组件添加到 Plane Prefab。

  2. 将 Prefab 拖动到 XR Origin 对象的 AR Plane Manager 组件的 Plane Prefab 属性中:

图片

图 20.14:设置平面可视化 Prefab

现在,我们有了查看平面的方法,但查看平面并不是我们能做的唯一事情(有时,我们甚至不希望它们可见)。平面的真正力量在于在现实生活中的表面上放置虚拟对象,点击特定的平面区域,并获取其实际位置。我们可以使用 AR Plane Manager 或通过访问我们的可视化平面的 AR Plane 组件来访问平面数据,但更简单的方法是使用 AR Raycast Manager 组件。

AR Raycast Manager 组件为我们提供了与 Unity 物理系统中的 Physics.Raycast 函数等效的功能,如您所记得,它用于创建从某个位置开始并沿指定方向延伸的假想射线,以便它们击中表面并检测确切的击中点。AR Raycast Manager 提供的版本,而不是与物理碰撞体碰撞,而是与跟踪对象碰撞,主要是点云(我们未使用它们)和我们正在跟踪的“平面”。我们可以通过以下步骤测试此功能:

  1. AR Raycast Manager 组件添加到 XR Origin 对象中。

  2. XR Origin 对象中创建一个名为 SpawnerPlacer 的自定义脚本。

  3. Awake 缓存中添加 ARRaycastManager 的引用。您需要将 using UnityEngine.XR.ARFoundation; 语句添加到脚本顶部,以便在这个类在我们的脚本中使用。

  4. 创建一个 List<ARRaycastHit> 类型的私有字段并实例化它;射线函数将检测我们的射线击中的每个平面,而不仅仅是第一个平面:

图片

图 20.15:存储碰撞的列表

  1. Update 中检查触摸屏是否被按下(Touchscreen.current.primaryTouch.press.isPressed)。您需要添加 using UnityEngine.InputSystem; 语句到文件顶部以使用新的输入系统。

  2. 在上一步的 if 语句中,为调用 AR Raycast ManagerRaycast 函数添加另一个条件,将触摸位置作为第一个参数,将碰撞列表作为第二个参数(Touchscreen.current.primaryTouch.position.ReadValue())。

  3. 这将在玩家触摸屏幕的方向上发射一条射线,并将碰撞存储在我们提供的列表中。如果击中某个物体,则返回 true,如果没有击中,则返回 false

  4. 在触摸的位置添加一个公共字段以指定要实例化的 Prefab。您只需创建一个 Sphere Prefab 并将其分配给此字段以进行测试;这里不需要添加任何特殊组件。请记住设置一个小的缩放比例。

  5. 在列表中存储的第一个击中对象的姿态属性的位置旋转字段中实例化 Prefab。击中按距离排序,所以第一个击中是最接近的。您的最终脚本应如下所示:

计算机程序截图,描述自动生成,中等置信度

图 20.16:射线投射器组件

在本节中,我们学习了如何使用 AR Foundation 创建新的 AR 项目。我们讨论了如何安装和设置框架,以及如何检测现实生活中的图像位置和表面,然后如何将对象放置在其上方。

正如您可能已经注意到的,我们从未点击播放来测试这个,遗憾的是,在撰写这本书的时候,我们无法在编辑器中测试这个。相反,我们需要直接在设备上测试。因此,在下一节中,我们将学习如何为移动设备(如 Android 和 iOS)进行构建。

为移动设备构建

Unity 是一个非常强大的工具,它能够轻松解决游戏开发中最常见的问题,其中之一就是为多个目标平台构建游戏。现在,为这些设备构建我们的项目部分在 Unity 中很容易完成,但每个设备在安装开发版本时都有其与 Unity 无关的细微差别。为了测试我们的 AR 应用程序,我们需要直接在设备上测试它。所以,让我们探索如何使我们的应用程序在 Android 和 iOS(最常见的移动平台)上运行。

在深入这个主题之前,值得提到的是,以下程序会随着时间的推移而大量变化,因此您需要在互联网上找到最新的说明。如果本书中的说明失败,Unity Learn 门户网站(learn.unity.com/tutorial/how-to-publish-to-android-2)可能是一个很好的替代方案,但请先尝试这里的步骤。

在本节中,我们将检查以下移动构建概念:

  • 为 Android 构建

  • 为 iOS 构建

让我们先讨论如何构建我们的应用程序,使其能够在 Android 手机上运行。

为 Android 构建

  1. 关闭 Unity 并打开Unity Hub

  2. 前往安装部分,找到您正在工作的 Unity 版本。

  3. 点击您正在使用的 Unity 版本右上角的轮形图标按钮,然后点击添加模块

图 20.17:向 Unity 版本添加模块

  1. 确保已勾选Android 构建支持以及点击其左侧箭头时显示的子选项。如果没有,请勾选它们,然后点击右下角的继续按钮以安装它们:

图 20.18:将 Android 支持添加到 Unity

  1. 通过勾选接受条款复选框并点击继续按钮,接受所有条款和条件提示。

  2. 打开本章中创建的 AR 项目。

  3. 前往构建设置文件 | 构建设置)。

  4. 从列表中选择 Android 平台并点击窗口右下角的 切换平台按钮:

表格,低置信度自动生成描述

图 20.19:切换到 Android 构建

要在 Android 上构建应用程序,我们需要满足一些要求,例如安装 Java SDK(不是常规的 Java 运行时)和 Android SDK,但幸运的是,Unity 的新版本会处理这些。为了确保我们已安装所需的依赖项,请按照以下步骤操作:

  1. 前往 Unity 首选项编辑 | 首选项(Windows 或 Unity)或首选项(Mac))。

  2. 点击 外部工具

  3. 确认 Android 部分中所有标有 …与 Unity 安装的选项都已勾选。这意味着我们将使用 Unity 安装的全部依赖项:

包含文本、报纸、截图、文档的图片,自动生成描述

图 20.20:使用已安装的依赖项

有一些额外的 Android ARCore 特定相关设置需要检查,您可以在 developers.google.com/ar/develop/unity-arf/quickstart-android 找到。如果您使用的是 ARCore 的新版本,这些设置可能会有所不同。您可以通过以下步骤应用它们:

  1. 前往 玩家设置编辑 | 项目设置 | 玩家)。

  2. 其他设置部分取消选择 多线程渲染自动图形 API

  3. 如果存在,从 图形 API 列表中删除 Vulkan

  4. 最小 API 级别设置为 Android 7.0

图形用户界面,应用程序,自动生成描述

图 20.21:ARCore 设置

  1. 脚本后端设置为 IL2CPP

  2. 选择 ARM64 复选框以支持 Android 64 位设备。

  3. 选择 覆盖默认包标识符并设置一些自定义内容,例如 com.MyCompany.MyARApp

  4. 前往 编辑 | 项目设置并选择 XR 插件管理选项。

  5. 插件提供者下检查 Google ARCore以确保它将在我们的构建中启用;如果没有,我们将看不到任何内容:

图形用户界面,文本,应用程序,自动生成描述

图 20.22:启用 ARCore 插件

现在,您可以从 文件 | 构建设置中通过使用 构建按钮构建应用程序,就像往常一样。这次,输出将是一个单独的 APK 文件,您可以通过将文件复制到您的设备并打开它来安装。请记住,为了安装未从 Play 商店下载的 APK,您需要将设备设置为 安装未知应用。该选项的位置因 Android 版本和您使用的设备而异,但通常位于 安全设置中。一些 Android 版本在安装 APK 时会提示您查看这些设置。

现在,我们可以在每次想要创建构建时复制并安装生成的 APK 构建文件。但是,我们可以让 Unity 为我们完成这项工作,使用构建和运行按钮。在构建应用后,此选项将查找通过 USB 连接到您的电脑的第一台 Android 设备,并自动安装应用。为了使此功能正常工作,我们需要按照以下方式准备我们的设备和电脑:

  1. 在您的设备上,在设备的设置部分找到构建号,其位置,同样,可能会根据设备的不同而变化。在我的设备上,它位于关于手机 | 软件信息部分:

手机的截图  描述由中等置信度自动生成

图 20.23:定位构建号

  1. 连续点击几次,直到设备提示您现在是一名程序员。此过程启用了设备中的隐藏开发者选项,您现在可以在设置中找到它。

  2. 打开开发者选项并开启USB 调试,这允许您的电脑在您的设备上拥有特殊权限。在这种情况下,它允许您安装应用。

  3. 如果使用 Windows,请从手机制造商的网站上安装 USB 驱动程序到您的电脑上。例如,如果您有一部三星设备,搜索Samsung USB Driver。另外,如果您找不到它,您可以查找Android USB Driver以获取通用驱动程序,但如果您的设备制造商有自己的驱动程序,这可能不起作用。在 Mac 上,这一步通常不是必要的。

  4. 连接您的设备(如果它已经连接,请重新连接)。您的电脑的允许 USB 调试选项将出现在设备上。选择始终允许来自此电脑并点击确定

在 Android 设备上启用 USB 调试

图 20.24:允许 USB 调试

  1. 接受出现的允许数据提示。

  2. 如果这些选项没有出现,请检查您的设备USB 模式是否设置为调试,而不是其他任何模式。

  3. 在 Unity 中,使用构建和运行按钮进行构建,并将apk保存到文件夹中。请耐心等待,因为第一次可能需要一段时间。

请记住,如果您在检测我们实例化播放器(我的情况是 Unity 标志)的图像时遇到问题,请尝试另一个图像。这可能会根据您的设备功能有很大的不同。

就这样!现在您的应用已经在设备上运行,让我们学习如何为 iOS 平台做同样的事情。

构建 iOS

开发 iOS 应用涉及一定的成本和要求。这包括使用仅在 macOS 上可用的 Xcode,以及可能需要获取 Mac 设备和 iOS 设备。此外,虽然拥有一个每年 99 美元的 Apple 开发者账户对于发布游戏是必要的,但对于测试目的则是可选的。

要创建 AR Foundation iOS 构建,您应该执行以下操作:

  1. 获取一台 Mac 电脑和一台 iOS 设备。

  2. 创建一个 Apple 开发者账户(在撰写本书时,您可以在developer.apple.com/创建一个)。

  3. 从 App Store 将最新版本的 Xcode 安装到您的 Mac 上。

  4. 检查您在 Unity Hub 上的 Unity 安装中是否有 iOS 构建支持。有关此步骤的更多信息,请参阅为 Android 构建部分。

  5. 构建设置下切换到 iOS 平台,通过选择iOS并点击切换平台按钮:

图形用户界面,应用程序描述自动生成

图 20.25:切换到 iOS 构建

  1. 前往编辑 | 项目设置并选择玩家选项。

  2. 其他设置中,如果尚未设置,请设置相机使用描述属性。这将是一个显示给用户的消息,告诉他们为什么我们需要访问他们的相机:

图 20.26:关于相机使用的消息

  1. 前往编辑 | 项目设置并选择XR 插件管理选项。

  2. 插件提供者下检查ARKit,以确保它将在我们的构建中启用;如果没有,我们将看不到任何内容:

图形用户界面,文本,应用程序描述自动生成

图 20.27:启用 ARKit 插件

  1. 构建设置窗口中点击构建按钮,创建一个构建文件夹,并等待构建完成。完成后,将打开一个包含生成文件的文件夹。

您会注意到构建过程的结果将是一个包含 Xcode 项目的文件夹。Unity 无法直接创建构建,因此它生成一个可以打开的 Xcode 软件项目。您需要遵循以下步骤来使用本书中使用的 Xcode 版本(13.4.1)创建构建:

  1. 双击生成文件夹内的.xcodeproj文件:

图形用户界面,应用程序描述自动生成

图 20.28:Xcode 项目文件

  1. 前往Xcode | 预设

  2. 账户选项卡中,点击窗口左下角的+按钮,并使用您注册为 Apple 开发者的 Apple 账户登录:

图形用户界面,应用程序描述自动生成

图 20.29:账户设置

  1. 将您的设备连接并从窗口的左上角选择它,现在应该显示为任何 iOS 设备。您可能需要首先解除设备的锁定,点击信任按钮,并等待 Xcode 完成设置您的设备,以便在列表中看到您的设备:

图 20.30:选择设备

  1. Xcode 可能会要求您安装某些更新以支持您的设备;如果需要,请安装它们。

  2. 在左侧面板中,点击文件夹图标,然后点击Unity-iPhone设置以显示项目设置。

  3. TARGETS列表中选择Unity-iPhone,然后点击Signing & Capabilities标签。

  4. 选择Automatically manage signing,并在提示中点击Enable Automatic按钮。

  5. Team设置中,选择说Personal Team的选项。

  6. 如果你看到一个Failed to register bundle identifier错误,只需将Bundle Identifier设置更改为另一个,始终遵守格式(com.XXXX.XXXX),然后点击Try Again直到问题解决。

一旦找到一个有效的配置,在 Unity 中将其设置为(Player Settings下的Bundle Identifier)以避免在每次构建时都需要更改它:

计算机截图,描述由中等置信度自动生成

图 20.31:设置你的 iOS 项目

  1. 点击窗口左上角的Play按钮,等待构建完成。在这个过程中,你可能需要输入密码几次,所以请这样做。

  2. 当构建完成时,请记住解锁设备。会有一个提示要求你这样做。请注意,除非你解锁手机,否则过程不会继续。如果失败,点击Cancel Running并再次尝试,这次确保设备已解锁;记得再次在列表中选择你的设备。此外,尝试使用可用的最新 Xcode 版本来支持设备上安装的最新 iOS 版本。

  3. 如果你看到一个Fetching Debug Symbols提示永远不会结束,请重新启动你的设备。

  4. 完成后,你可能看到一个错误,说应用无法启动,但无论如何它已经被安装了。如果你尝试打开它,它会告诉你需要信任该应用的开发者,你可以通过访问你的设备设置来完成这一操作。

  5. 从那里,转到General | VPN & Device Management并选择列表中的第一个开发者。

  6. 点击蓝色Trust…按钮,然后Trust

  7. 尝试再次打开应用。

  8. 请记住,如果你在检测我们实例化玩家(我的情况是鹅卵石图像)的位置的图像时遇到麻烦,请尝试另一个图像。这可能会根据你设备的性能有很大的不同。

在本节中,我们讨论了如何构建一个可以在 iOS 和 Android 上运行的 Unity 项目,从而让我们能够创建移动应用——具体来说是 AR 移动应用。像任何构建一样,我们有一些方法可以遵循来分析和调试,就像我们在查看 PC 构建时看到的那样,但在这里我们不会讨论这一点。既然我们已经讨论了 iOS 的构建过程,让我们将注意力转向创建一个简单的 AR 游戏这个令人兴奋的任务。我们将从开发核心游戏机制开始,包括生成玩家和敌人。

创建一个简单的 AR 游戏

如我们之前讨论的,想法是创建一个简单的游戏,我们可以移动我们的玩家,同时移动现实生活中的图像,并且只需点击我们想要它们出现的位置,例如墙壁、地板、桌子等,就可以添加一些敌人生成器。我们的玩家将自动射击最近的敌人,敌人将直接射击玩家,因此我们唯一的任务就是移动玩家以避免子弹。我们将使用与本书主要项目非常相似的脚本来实现这些游戏机制。

在本节中,我们将开发以下 AR 游戏功能:

  • 创建玩家和敌人

  • 编写玩家和敌人行为代码

首先,我们将讨论如何让我们的玩家和敌人在应用中显示,特别是现实世界的位置,然后我们将让它们移动并互相射击以创建特定的游戏机制。让我们从生成开始。

创建玩家和敌人

你的Player Prefab 应该看起来如下:

图 20.32:初始“Player”Prefab

敌人需要做更多的工作,如下所示:

  1. 创建一个名为Spawner的 Prefab,其图形是你想要的生成器图形(在我的情况下是一个圆柱体)及其实际生活大小(小规模)。

  2. 添加一个自定义脚本,每隔几秒生成一个 Prefab,如下面的截图所示。

  3. 你会注意到使用Physics.IgnoreCollision来防止Spawner GameObject 与生成的 GameObject 发生碰撞,获取两个对象的碰撞器并将它们传递给函数。你也可以使用层碰撞矩阵来防止碰撞,如果你更喜欢这样做的话:

计算机程序截图,描述自动生成,置信度中等

图 20.33:生成器脚本

  1. 创建一个带有所需图形(在我的情况下是一个胶囊)和带有Is Kinematic复选框勾选的Rigidbody组件的Enemy Prefab。这样,敌人会移动,但不是通过物理。请记住考虑敌人的实际生活大小。

  2. 设置SpawnerPrefab属性,以便它以我们希望的时间频率生成敌人:

图 20.34:配置生成器

  1. 在 XR Origin 对象中设置SpawnerPlacer的 Prefab,以便它生成我们之前创建的Spawner Prefab。

第一部分就到这里。如果你现在测试游戏,你将能够点击应用中检测到的飞机,并看到生成器开始创建敌人。你还可以查看目标图像,看到我们的立方体玩家出现。

现在我们已经在场景中有对象了,让我们让它们做一些更有趣的事情,从敌人开始。

编写玩家和敌人行为代码

敌人必须移动到玩家位置才能射击,因此它需要能够访问玩家的位置。由于敌人已经被实例化,我们不能将玩家引用拖动到预制体中。然而,玩家也已经实例化,因此我们可以向玩家添加一个使用 Singleton 模式的 PlayerManager 脚本。记住,Singleton 模式确保在游戏中只有一个脚本的实例,这是我们之前在第八章胜利或失败:胜利和失败条件中讨论的概念。

要做到这一点,请按照以下步骤操作:

  1. 创建一个类似于以下截图所示的PlayerManager脚本并将其添加到玩家中:

包含文本、截图、字体、行描述自动生成

图 20.35:创建 PlayerManager 脚本

  1. 现在敌人有了玩家的引用,让我们通过添加一个LookAtPlayer脚本,如图所示,让他们看向玩家:

计算机程序截图,描述自动生成,置信度低

图 20.36:创建 LookAtPlayer 脚本

  1. 此外,添加一个类似于以下截图所示的简单MoveForward脚本,使敌人不仅查看玩家,而且向他们移动。由于LookAtPlayer脚本使敌人面向玩家,因此沿着Z轴移动的脚本就足够了:

包含文本、截图、字体、行描述自动生成

图 20.37:创建 MoveForward 脚本

现在,我们将处理玩家移动。记住,我们的玩家是通过移动图像来控制的,所以在这里,我们实际上是在指旋转,因为玩家需要自动查看并射击最近的敌人。要完成此操作,请按照以下步骤操作:

  1. 创建一个Enemy脚本并将其添加到Enemy预制体中。

  2. 创建一个类似于以下截图所示的EnemyManager脚本并将其添加到场景中的空EnemyManager对象中:

包含文本、截图、字体、行描述自动生成

图 20.38:创建 EnemyManager 脚本

  1. Enemy脚本中,确保将对象注册到EnemyManagerall列表中,就像我们在本书的主要项目中使用WavesManager时那样:

包含文本、截图、字体、行描述自动生成

图 20.39:创建 Enemy 脚本

  1. 创建一个类似于以下截图所示的LookAtNearestEnemy脚本并将其添加到Player预制体中,使其查看最近的敌人:

计算机程序截图,描述自动生成,置信度中等

图 20.40:查看最近的敌人

现在我们看到对象按预期旋转和移动,唯一缺少的是射击和造成伤害:

  1. 创建一个类似于以下截图所示的Life脚本,并将其添加到玩家敌人组件中。请记住设置生命值字段的值。你将看到这个版本的Life,而无需在每一帧检查生命值是否达到零。我们已经创建了一个Damage函数来检查是否造成了伤害(Damage函数被执行),但这本书项目的另一个版本也可以工作:

包含文本、截图、字体、行描述自动生成

图 20.41:创建生命组件

  1. 创建一个具有所需图形的Bullet预制体,将碰撞器上的是触发器复选框勾选,一个带有是运动学复选框勾选的Rigidbody组件(运动学触发碰撞器),以及适当的真实尺寸。

  2. MoveForward脚本添加到子弹预制体中,使其移动。请记住设置速度。

  3. Spawner脚本添加到玩家敌人组件中,并将子弹预制体设置为要实例化的预制体,以及所需的实例化频率。

  4. 将类似于以下截图所示的Damager脚本添加到子弹预制体中,使子弹对其接触的对象造成伤害。请记住设置伤害值:

包含文本、截图、字体、行描述自动生成

图 20.42:创建伤害脚本 – 第一部分

  1. 将类似于以下截图所示的AutoDestroy脚本添加到子弹预制体中,使其在一段时间后消失。请记住设置销毁时间:

包含文本、截图、字体、行描述自动生成

图 20.43:创建伤害脚本 – 第二部分

就这样!正如你所见,我们基本上是使用与主游戏几乎相同的脚本创建了一个新游戏,主要是因为我们设计它们时考虑了通用性(而且游戏类型几乎相同)。当然,这个项目还有很多可以改进的地方,但我们有一个很好的基础项目来创建令人惊叹的 AR 应用。

摘要

在本章中,我们介绍了 AR Foundation Unity 框架,探讨了如何设置它,以及如何实现几个跟踪功能,以便我们可以在真实物体上定位虚拟对象。我们还讨论了如何构建我们的项目,使其能够在 iOS 和 Android 平台上运行,这是我们在撰写本文时测试 AR 应用的唯一方式。最后,我们基于主项目中创建的游戏创建了一个简单的 AR 游戏,但对其进行了修改,使其适合在 AR 场景中使用。

通过这些新知识,你将能够开始作为 AR 应用开发者的职业生涯,通过检测真实物体的位置,创建将虚拟物体添加到真实物体上的应用。这可以应用于游戏、培训应用和模拟。你甚至可能发现新的应用领域,所以利用这项新技术及其新可能性吧!

好吧,这就是通过 Unity 2023 的旅程的结束。我们非常高兴你在书中达到了这个阶段。我们希望这些知识能帮助你利用市场上最灵活和强大的工具之一:Unity 来提升或开始你的游戏开发职业生涯。我们希望有一天能看到你的作品!路上见!

在 Discord 上了解更多信息

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

第二十一章:巨大世界:DOTS 简介

虽然 Unity 是一个强大且性能良好的引擎(当正确使用时),但你拥有的 GameObject 数量有限,否则会达到性能限制。虽然面向对象编程OOP)范式提供了一种方便的方式来编写游戏,但由于其内部工作方式,它并不总是能充分利用所有现代 CPU 功能,如缓存、单指令多数据SIMD)操作和多线程。虽然在 OOP 中使用这些功能并非不可能,但这样做并非易事,可能会导致难以追踪的 bug。Unity 针对该问题的解决方案是发布第一个生产就绪版本的 DOTS 包,这将帮助我们解决这些问题。

在本章中,我们将探讨以下构建概念:

  • 理解 DOTS 是什么

  • 创建我们的第一个 DOTS 游戏

  • 在 DOTS 中创建游戏玩法

让我们先了解一下 DOTS 是什么以及为什么我们应该使用它。

理解 DOTS 是什么

Unity 数据导向技术堆栈(DOTS)是一组 Unity 包,使我们能够轻松编写数据导向的代码。虽然 DOTS 堆栈中有许多包,但让我们专注于三个关键的包,它们是其余部分的支柱:

  • 实体:Unity 采用 ECS 模式。它用实体、组件和系统(ECS,Unity 中一种将数据(组件)与逻辑(系统)分离的范式,增强性能和可扩展性)替换 GameObject 和 MonoBehaviours,这是一种缓存友好的方式来存储和更新我们的场景对象。它提供了相当大的性能提升,尤其是在拥有大量对象的游戏中。

  • 作业:Unity 创建多线程代码的方式。它将处理作业的数据和代码分组。它们在作业调度器的并行执行下运行,该调度器保证尊重作业之间的显式依赖关系。这避免了经典的多线程问题,如死锁和竞态条件。Unity 使用作业来提高其不同系统的性能,如渲染或物理。它不能应用于未经重大修改的常规 MonoBehaviour 方法,如 Update,但实体与作业高度兼容。

  • Burst:一种代码编译器,可以将 C#脚本转换为高度优化的本地代码。它使用 LLVM,在编译时应用高级优化。要转换的代码需要使用 C#的一个子集,称为高性能 C#HPC#),因此它不能应用于任何脚本而不进行(有时)相当大的修改。

虽然这些包可以单独使用(尤其是作业和突发包),但想法是将它们混合起来,以并行化我们使用作业编写的 ECS 游戏逻辑,同时 Burst 使其尽可能快地运行。其他 DOTS 包提供不同的功能,如渲染(实体图形)、物理(Unity 物理和 Havok)和网络(实体网络代码)。

所有这些都听起来很棒,但使用这项技术时有一些注意事项。以下是最重要的几点:

  • 这是从 GameObject 方法的基本转变。这意味着你必须重新学习如何做你已经习惯用 GameObject 和 MonoBehaviours 做的事情。

  • 学习曲线可能很陡峭,这取决于你的编程经验。这是由于 HPC#的限制导致的 C#功能有限,需要学习自定义 API,以及其代码的多线程特性。即使 Jobs 包易于使用,它也增加了单线程代码中不存在的挑战。

  • 它还处于早期阶段。短期内其 API 可能发生重大变化(变得更好!)幸运的是,面向数据的原则不会改变,这意味着我们不需要再次学习它们,只需学习相关的 API。

  • 一些 GameObject 功能在 DOTS 中尚不可用,如 UI、音频、地形或动画。目前,您需要在 GameObject 中实现这些功能,并将它们与其相应的实体同步。这是一项艰巨的任务,需要几个自定义解决方案。

不论如何,这些障碍并没有阻止使用 DOTS 制作雄心勃勃的游戏,如 V-Rising 和 Cities Skylines 2。讨论 DOTS 的注意事项的目的不是恐吓,而是理解何时使用它是合理的。我刚才提到的游戏是 DOTS 发光的绝佳例子,因为这些游戏模拟了拥有大量对象的大型世界。以下是一个不全面的列表,列出了可能从使用 DOTS 中受益的游戏:

  • 需要复杂模拟逻辑以高效运行的大型世界和多个对象。此类示例包括大型多人在线游戏(MMOs)、城市建造游戏或模拟庞大世界的 RPG,如 Cyberpunk 2077 或刺客信条。

  • 复杂的模拟需要现实生活中的行为,如风洞模拟、建筑可视化,甚至星系星模拟。

  • 基于 GameObject 的游戏的特定功能可以使用 DOTS,例如足球场的观众群或复杂的粒子系统。DOTS 和 GameObject 可以在同一游戏中共存。

另一方面,运行良好的简单游戏不需要 DOTS 的困难和当前限制。例如,需要两个主要对象(战斗者)和最小环境的格斗游戏不是好的候选者。其他例子是像 Firewatch 这样的休闲开放世界探索游戏,即使它们有探索的大型景观,但逻辑足够简单,可以通过使用 GameObject 的一些巧妙技巧来应对。最终,Firewatch 没有使用 DOTS。

这些限制将在未来得到解决,使得所有类型的游戏使用 DOTS 都变得可行,但到目前为止,让我们避免以可能风险其开发的方式开始一个项目。我并不是说 DOTS 是危险的,但我想清楚地表明,并不是每个游戏都值得从头开始重新学习新技术所付出的努力和风险。

如果你经过仔细考虑和研究后认为你的未来项目可能从使用 DOTS 中受益,那么请继续跟随我们;在下一节中,我们将通过一个简单的入门项目来学习 DOTS 的工作原理。

创建第一个 DOTS 游戏

要创建具有高性能的 DOTS 游戏,有很多东西需要学习,所以让我们从一个简单的项目开始,该项目包含向前移动的立方体。强调立方体——我们将拥有成千上万个!我知道这不是最令人兴奋的项目,但请耐心等待,因为在本章的在 DOTS 中创建游戏玩法部分,我们将将其转换成一个实际(但简单)的游戏。

在本节中,我们将探讨以下 DOTS 概念:

  • 创建 DOTS 项目

  • 使用子场景创建实体

  • 创建组件和烘焙器

  • 使用工作(Jobs)和爆发(Burst)创建系统

  • 调试系统工作(jobs)和查询(queries)

让我们先讨论如何设置一个与 DOTS 兼容的项目。

创建 DOTS 项目

创建 DOTS 项目的第一步是你应该在本章的这个阶段已经知道的:创建一个全新的 URP 项目。如果你不确定如何操作,请参阅第一章踏上你的 Unity 之旅。确保你使用的是3D (URP)模板,因为 DOTS 只与 URP 和 HDRP 兼容,而本书没有涵盖 HDRP。如果需要,你也可以将 DOTS 添加到现有项目中。

现在,是时候安装所需的 DOTS 包了。我们提到有三个关键包:实体(Entities)、工作(Jobs)和爆发(Burst),我们也提到其余的包都是基于它们构建的。除了这些包之外,我们还需要实体图形(Entities Graphics)来渲染我们的实体。鉴于这个包依赖于核心 DOTS 包,只需安装它就会带来神圣的三个包。

如果你忘记了如何安装包,请参阅第三章从蓝图到现实:使用地形和 ProBuilder 构建中的安装 ProBuilder部分。那些说明安装了ProBuilder包,但步骤可以推广到安装这个包。在撰写本章时,实体图形版本为 1.0.16。

图 21.1:安装实体图形以及作为依赖的实体包

你可能会收到有关Burst包更新的消息,这可能会导致 Unity 不稳定。像往常一样,在安装新包时重新启动 Unity 以修复问题。

下一步是更改 URP 设置以使用 Forward+,这是 DOTS 用于渲染的新 URP 渲染路径。执行以下操作:

  1. 选择当前使用的 SRP 资产,如第十二章配置高性能阴影部分中所述。在我的情况下,该资产被称为URP-HighFidelity,因此你也可以在项目面板中通过名称搜索它。

图 21.2:查看当前 SRP 资源的使用情况。

  1. 在 SRP 资源的渲染器列表中,双击其第一个元素以选择渲染器资源。在我的情况下,它被称为URP-HighFidelity-Renderer,所以再次,你可以在项目面板中搜索它。

图片

图 21.3:选择 SRP 资源使用的渲染器资源

  1. 在渲染器资源中,将渲染路径选项设置为Forward+

图片

图 21.4:激活 Forward+作为当前渲染路径

  1. 在编辑器中按下播放按钮以确保一切正常工作。如果你在控制台看到像图 21.5中的警告消息,请确保你正在修改正确的资源。控制台消息将告诉你需要更改的资源名称。

图片

图 21.5:在播放 DOTS 项目且未使用 Forward+时的警告消息

考虑到如果游戏允许更改质量设置并且游戏具有选项菜单,可能还会使用其他 URP 渲染器资源。在这种情况下,将所有渲染器资源更改为使用 Forward+。

关于 Forward+的讨论超出了本章的范围。更多信息,请查看以下链接:docs.unity3d.com/Packages/com.unity.render-pipelines.universal@14.0/manual/rendering/forward-plus-rendering-path.html

有一个额外的步骤对于 DOTS 工作不是必需的,但它会使编辑器体验更快。只需转到编辑 | 项目设置 | 编辑器并勾选进入播放模式选项复选框。这将避免在编辑器中每次播放游戏时发生的昂贵域重新加载过程。虽然这可能对于基于 GameObject 的项目是必要的,但纯 DOTS 项目可以避免它。在制作混合 DOTS 游戏时启用此选项。

有方法可以在 GameObject 项目中使用播放模式选项。查看以下文档链接以获取更多信息:docs.unity3d.com/Manual/ConfigurableEnterPlayMode.html

现在我们已经正确设置了项目,让我们创建我们的第一个实体。

使用子场景创建实体

虽然实体与 GameObject 的工作方式不同,但你仍然会在编辑器中使用 GameObject 来创建实体。想法是使用你熟悉的 Unity 工具,如场景层次结构检查器面板,甚至预制体。这是通过将特定的 GameObject 及其组件转换为它们的实体等效物来实现的。我们将在本章后面通过脚本实例化实体。

要在编辑器中将 GameObject 转换为实体,我们将使用烘焙系统。它使用烘焙器——负责将特定组件转换为其实体等效的脚本。一些 DOTS 包附带烘焙器以转换特定的 Unity 组件;例如,Entities Graphics 包含将MeshFilterMeshRenderer组件转换为其实体版本的烘焙器。转换后的版本可能与原始版本有很大差异,通常一个组件可以转换为多个 ECS 组件。如果您没有安装 Entities Graphics 包,MeshFilterMeshRenderer在转换时将被丢弃,因为项目中没有为它们提供烘焙器,这将导致您的实体无法渲染。在本章的后面部分,我们将创建自己的烘焙器。

告诉 Unity 哪些 GameObject 应该被转换的方法是通过子场景,这是一个将仅包含要转换的 GameObject 的另一个场景。主要场景通过使用负责转换和加载子场景中的实体的 SubScene 组件来加载实体子场景。请注意,转换发生在编辑器中,这意味着在从构建中玩游戏时不会发生转换。这允许通过场景序列化的方式快速加载子场景。

要创建子场景,请执行以下操作:

  1. 在现有场景中,在层次结构中的任何空白空间处右键单击,并选择New Sub Scene | Empty Scene选项。如果您看到该选项变灰,请确保首先保存主场景。

图 21.6:创建子场景

  1. 在保存文件提示中,像保存常规场景一样保存子场景。

  2. 这将在主场景中创建一个带有SubScene组件的 GameObject。它将具有 Scene Asset 属性设置为引用我们创建的子场景文件。如果New Sub Scene选项不可用,手动创建子场景资产和 GameObject,直到它看起来像图 21.7

图 21.7:加载我们刚刚创建的子场景资源的子场景组件

  1. 确保在层次结构中子场景 GameObject 的复选框被勾选,因为它允许您编辑子场景并向其中添加对象。

  2. 使用GameObject | 3D Object | Cube创建一个立方体,并将其拖动到层次结构中的子场景内。确保您在子场景内创建它,注意图 21.8中看到的缩进。如果它是在子场景外创建的,立方体看起来是正确的,但因为它仍然是一个常规的 GameObject。

图 21.8:显示立方体位于子场景内的缩进

  1. 要确认立方体已被转换,选择它并检查Inspector底部的Entity Baking Preview。点击它以显示从立方体的原始组件转换而来的 ECS 组件列表。

图 21.9:显示从您的原始立方体创建的 ECS 组件的 Entity Baking Preview

  1. 确保场景相机指向立方体,然后开始游戏。你必须看到立方体在 游戏 面板中被渲染。确保相机不在子场景内部;我们需要它成为一个经典的 GameObject。

就这样!你已经创建了你的第一个实体!当然,它并没有做什么特别的事情,但现在我们可以开始添加自定义行为到它上面,首先是通过添加 ECS 组件。

创建组件和烘焙器

在创建 ECS 组件之前,让我们先讨论一下它们与 GameObject 组件的不同之处。将 GameObject 组件想象成一个工具箱中的单个工具——每个工具都是为了特定的任务而设计的,比如渲染形状或检测碰撞。它们独立工作,但共同构成了你在游戏中对象的功能。另一方面,ECS 组件更像是工厂生产线上的原材料;它们本身不执行任何操作。相反,它们被系统高效地处理和组装,以创建所需的行为。这种从使用单个工具(GameObject 组件)到处理原材料(ECS 组件)的转变,使得 Unity 能够优化游戏性能,尤其是在包含许多对象的复杂场景中。

虽然我们仍然想要向实体添加组件以添加行为,但 ECS 组件的责任仅限于仅持有数据。这意味着它们不会有 Updates 或其他事件,如 OnTriggerEnter;它们只是纯粹的数据。它们可以有方法来方便地操作这些数据,但不能是核心游戏逻辑。

你可能会想知道我们的逻辑将放在哪里,答案是系统。我们将在本章后面创建它们,但首先,让我们添加我们的游戏逻辑所需的工作实体数据。

我所说的数据是指像我们角色的子弹、分数、健康点、位置等这样的变量。我们通常在 MonoBehaviours 中创建字段来达到这样的目的,这里也没有不同,但这次,我们不会使用继承自 MonoBehaviour 的类。相反,我们将使用实现 IComponentData 的 struct。Struct 允许 Entities 包以与 垃圾回收器 工作方式不同的方式管理我们组件的内存,通过以利用 CPU 缓存的方式组织它。Structs 还与 HPC# 兼容,这是与 Burst 兼容的 C# 子集。关于 Burst 和 CPU 缓存将在本章后面进行讨论。

C# 中 struct 和 class 的区别超出了本书的范围。你可以在这里了解更多信息:learn.microsoft.com/en-us/dotnet/standard/design-guidelines/choosing-between-class-and-struct

你可以通过在项目面板中使用加号按钮创建一个脚本,选择C#脚本选项,并按你想要创建的组件命名来创建一个组件。不要使用检查器中的添加组件按钮创建脚本,因为我们需要在将组件添加到待转换的 GameObject 之前执行一些额外步骤。我们建议将所有 ECS 组件的脚本放在一个Scripts/ECS/Components文件夹中,以便轻松区分你的常规 Unity 脚本和 ECS 脚本。

由于我们想让我们的立方体向前移动,我们需要一个速度值。具有转换数据(如位置和旋转)的组件是由 Entities 包中包含的 Transform 烘焙器添加的。要创建我们的速度 ECS 组件,首先创建一个名为Velocity的脚本,并在其中放入以下代码。

图 21.10:一个包含实体速度的 ECS 组件

记住,我们不应该使用类;而应该使用结构体。虽然你可以使用类,但它们将是一种不同类型的组件(受管理组件),性能会低得多,而且我们现在不需要它们。此外,注意我们如何包含了Unity.Entities命名空间。就这样!我们有了我们的第一个 ECS 组件!但我们的任务还没有完成。如果你尝试将这个组件添加到子场景中的立方体,它将不会工作。我们的组件不会在检查器添加组件按钮中列出,你甚至不能像往常一样拖放脚本文件。记住,烘焙系统需要一个用于我们的 ECS 组件的烘焙器以及一个用于转换的MonoBehaviour

策略是使用 GameObject 和MonoBehaviours来使用现有的 Unity 工具创建场景,并通过烘焙器将它们转换为实体。到目前为止,我们创建了 ECS 组件,但我们仍然需要该组件的MonoBehaviour版本以将其添加到子场景的 GameObject 中。我们组件的MonoBehaviour版本被称为编写组件,而 ECS 组件是运行时组件。要创建编写组件,创建一个常规组件,从编辑器配置我们想要的字段。在下面的图像中,你可以看到VelocityAuthoring组件:

图 21.11:我们的速度运行时组件的编写组件

这只是我们习惯创建的经典MonoBehaviour组件,具有与我们的运行时组件相同的字段。现在,我们有了可以添加到子场景中的编写 GameObject 的组件,只需将其添加到立方体即可。如果你检查检查器底部的实体烘焙预览面板,没有添加新的 ECS 组件。我们仍然需要创建烘焙器将VelocityAuthoring转换为Velocity。烘焙器是一个从Baker类继承的类,它将编写组件转换为运行时组件。在下一张图像中,你可以看到我们的VelocityAuthoring组件的烘焙器。

图 21.12:将 VelocityAuthoring 组件转换为运行时组件的烘焙器

如你所见,我们创建了一个从 Baker<VelocityAuthoring> 继承的类,这意味着当烘焙系统进行转换时,这将作为 VelocityAuthoring 的烘焙器。在 Bake 方法中,我们接收要转换的实例化创作组件,访问其数据以创建运行时组件。

首先,我们使用 GetEntity 方法获取为转换的 GameObject 创建的实体。它的唯一参数突出显示了我们想要在播放模式期间移动此对象。因此,它是动态的。然后,我们创建运行时组件的实例,并将其速度值设置为与创作组件相同。这样,我们确保在检查器中设置的值是运行时组件将拥有的值。最后,我们使用 AddComponent 方法将运行时组件添加到转换后的实体。

有关 GetEntity 中使用的不同变换使用标志的更多信息,请参阅此链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/transforms-usage-flags.html

如果你保存了你的脚本并确保将创作组件添加到立方体中,实体烘焙预览 窗口应该会显示 Velocity 运行时组件。你可以点击它并确认其值与创作组件相同。

图 21.13:实体烘焙预览显示了我们的运行时组件如何反映创作组件的值

虽然创建组件看起来需要很多工作,但在实际项目中,这成为一种强大的工具。创作组件可能会添加几个运行时组件,有时是条件性的。例如,Unity 物理包 中的 Rigidbody 烘焙器将根据是否勾选或取消勾选 Is Kinematic 添加不同的组件。

既然我们已经有了所需的数据,让我们创建一个系统来移动我们的立方体。

使用作业和 Burst 创建系统

正常的 MonoBehaviours 不仅具有数据,还有创建它们所代表的行为的逻辑。有时,它们需要来自其他组件的数据,例如 Transform 中的位置,因此它们使用 GetComponent 等方法来访问其他组件的数据。在 ECS 中,这工作方式相当不同。

给定的 ECS 组件仅包含数据,逻辑则进入一个称为 系统 的不同位置。它们将游戏逻辑,例如移动或旋转对象,应用于需要此类逻辑的实体。系统通过其数据来识别哪些实体必须应用其逻辑。系统将查询所有具有特定组件集的实体,以便其逻辑能够工作,并将迭代结果实体以应用该逻辑。在这种情况下,我们想要移动实体,但不是所有实体,只是具有 Velocity 组件和位置数据的实体。

由于所有 GameObject 都有变换组件,因此我们的系统需要具有位置数据这一说明可能令人困惑。在 DOTS 中这并不一定正确,这在前文提到的TransformUsageFlags属性信息框中有更详细的解释。在这个项目中,我们将始终使用TransformUsageFlag.Dynamic,因此对于这种情况,我们可以假设所有实体都将具有诸如位置、旋转和缩放之类的变换数据。

要创建我们的运动系统,首先,我们需要创建一个脚本,如下面的图像所示:

图 21.14:一个空 ECS 系统,用作创建我们的运动系统的基础

一个系统由一个实现ISystem接口的部分结构组成。由于 DOTS 依赖于代码生成器,因此需要部分实现,这意味着使系统正常工作的许多样板代码将位于为我们自动创建的另一个MovementSystem部分结构中。我们还添加了一个OnUpdate方法,这是 MonoBehaviour 的Update方法的等效方法,但有一个非常重要的区别。

再次,partial关键字超出了本书的范围。有关更多信息,请查看以下链接:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/partial-classes-and-methods

正常的Update方法属于特定的组件,这意味着Update将在每个组件实例上执行一次。如果我们有 100 个带有MonoBehaviourUpdate方法的GameObjects,那么该方法将在每帧执行 100 次,每次针对一个GameObject。但在这个情况下,默认情况下,游戏中始终有一个我们的系统实例,它由实体包自动创建,不管场景中存在哪些实体。这意味着我们的运动系统OnUpdate方法将在我们开始玩游戏时每帧执行一次,仅仅因为系统脚本存在。有方法可以防止自动系统创建,拥有多个系统实例,并防止OnUpdate执行,但让我们保持简单。

保存好系统脚本后,为了确保其正常工作,请玩游戏并在游戏模式中,在窗口 | 实体 | 系统中打开系统面板。此窗口将列出所有正在运行的系统和它们的性能指标。

图 21.15:显示我们的运动系统的系统窗口。

下一步是迭代所有具有VelocityLocalTransform组件的实体。后者是 Transform bakers 添加的几个 Transform ECS 等效组件之一。迭代实体有多种方式,但让我们首先看看最常见且性能最高的方式,即使用作业。作业是包含我们想要在多个线程中应用逻辑和数据(如 delta 时间或要移动的对象的位置)的结构体。在我们的案例中,数据将是实体组件和一些额外数据,如 delta 时间。为了迭代实体,我们有一种名为IJobEntity的作业类型。有大量的作业类型用于不同的目的,但对于 ECS,我们大部分时间将使用IJobEntity

在下面的图中,你可以看到一个移动所有具有VelocityLocalTransform的实体的作业脚本:

图 21.16

图 21.16:一个移动具有局部变换和速度组件的实体的作业

这里发生了很多事情,所以让我们逐个分析这个脚本:

  1. 我们在脚本中创建了一个部分结构体,该结构体实现了IJobEntity,该脚本使用了Unity.EntitiesUnity.Transforms命名空间。

  2. 这个结构体有一个带有两个参数的执行方法,一个是LocalTransform类型,另一个是Velocity类型。这些参数与我们需要移动实体的确切组件相匹配并非巧合。在IJobEntityExecute方法中定义的参数用于告诉 ECS 我们想要迭代具有此类组件的实体,忽略没有这些组件的实体。

  3. LocalTransform参数前缀为ref关键字,而Velocity前缀为in关键字。这些是告诉 ECS 我们想要对这些组件拥有什么权限的方式。Ref表示我们将修改LocalTransform,而in表示我们只读取Velocity的数据。为了确保任何作业都可以安全地与其他作业并行执行,作业系统需要知道它们中的数据将如何被处理。两个写入相同数据的作业(例如,以不同方式移动对象的两个作业)不能并行执行,以防止竞态条件和使用锁。然而,如果只读操作是线程安全的,那么可以并行执行读取相同数据的两个作业。始终尽可能将参数定义为in(只读),只在必要时使用ref

  4. 我们通过将速度乘以 delta 时间来计算移动的米数。我们通常不是使用Time.deltaTime来访问 delta 时间。为了允许安全的作业并行化,它们被设计成只处理作业内部的数据,尽可能避免访问静态变量。这就是为什么 delta 时间是一个结构体字段,这样当我们在我们系统中实例化作业时,我们将填充这些数据。

  5. MonoBehaviours 中,直接修改由 transform.position 返回的轴(不将其存储在变量中)是不可能的;因为 transform.position 返回的是位置的一个副本,修改它将引发错误。同样适用于任何其他获取器。但在 ECS 中,这是完全有效的,因为我们是通过引用而不是副本来操作数据的。我们通过 ref 关键字访问了 LocalTransform,这意味着我们直接访问了位置内存。在常规的 MonoBehaviours 中,这是不可能的,因为我们的 GameObjects 的位置存储在 Transform 的 C++ 方面,因此需要副本。

如果作业不是 Burst 编译的(关于这一点稍后讨论),则作业可以访问可变静态数据。但即使这是可能的,也是非常危险的,因为 ECS 安全系统无法防止两个作业修改此类静态数据。这就是为什么将数据复制到作业中,就像我们使用 Time.deltaTime 一样,更安全。

现在,MovementSystem 的唯一责任是实例化作业,填写其数据,并安排它。通过安排它,作业系统将负责检查何时执行我们的作业是安全的,考虑到其他系统的作业可能如何读取和写入我们的作业所需的数据。记住,使用 refin 关键字定义我们的组件的读写或只读访问权限的想法是允许作业调度器考虑这些因素。

我们系统的 OnUpdate 方法将如下所示:

图片

图 21.17:移动系统实例化 MoveJob 并安排它

如您所见,我们只是实例化作业,将 DeltaTime 字段填充为这一帧的 delta time,然后使用 ScheduleParallel 安排作业。首先要注意的是,我们使用了 SystemAPI.Time.DeltaTime,这是 ECS 获取帧 delta time 的方法。

ECS 有自己的计时机制,虽然我们仍然可以使用 Time.DeltaTime,但我们应该坚持使用 ECS 版本来与系统的其余部分正常工作。此外,我们没有指定作业将迭代的实体。系统代码生成器知道我们想要迭代的实体,基于系统作业的 Execute 方法参数,所以我们不需要担心向作业提供此数据。

最后,我们使用了 ScheduleParallel 方法,不仅告诉作业调度器安排我们的作业,还告诉它我们想要并行迭代实体。这意味着作业工作线程(通常每个 CPU 核心一个)将处理不同的实体,通过分散工作来减少执行我们的逻辑所需的时间。您也可以使用 Schedule,它在一个核心中处理所有我们的实体,但仍然与其他作业并行。目前,我们将继续使用 ScheduleParallel 大多数情况。

现在我们需要 DOTS 三叉戟的最后一部分,Burst。记住,Burst 会将 HPC#兼容的代码转换为高性能的本地代码。以下是一个非详尽的 HPC#要求的列表:

  • 使用托管引用(对象)是不可能的。这包括字符串、数组、列表、字典以及大多数 C#库,如System.Collections

  • 异常(try/catch)在编辑器中工作,但在构建中会终止执行。

  • Debug.Log的支持仅限于常量字符串、字符串插值和string.Format

  • 静态只读数据和常量是安全的。除非使用SharedStatic结构,否则可变静态数据不安全:docs.unity3d.com/Packages/com.unity.burst@1.8/manual/csharp-shared-static.html

到目前为止,我们在代码中使用了 HPC#,所以我们只需要使用BurstCompile属性标记代码以进行 Burst 编译。在系统中,您可以将此属性添加到OnUpdate方法中,对于作业,将属性添加到job结构中以 Burst 编译其Execute方法。

图 21.18:Burst 编译我们的系统和作业

在代码中遵循一些注意事项可以使 Burst 工作得更快。查看此链接获取更多信息:docs.unity3d.com/Packages/com.unity.burst@1.8/manual/optimization-overview.html

现在,您可以玩游戏并观察我们的立方体如何向前移动。您可以随意将立方体复制到场景中,直到您有数百甚至数千个。看看游戏性能是否受到影响!

当实体配置错误,例如忘记添加Velocity组件时,系统可能无法按预期工作。这可能导致系统找不到它们需要的实体。让我们看看如何调试系统以查看它们找到的实体、它们正在安排的作业以及检查它们的代码是否是 Burst 编译的。

调试系统作业和查询

系统通过迭代具有特定组件的实体,使用作业和其他方法来应用逻辑。为了调试一个不工作的系统,我们应该检查它是否正在查找实体。为此,系统代码生成器创建了EntityQuery的实例,这是一种负责查找具有特定组件的实体的类型。我们的MoveJob作业需要具有LocalTransformVelocity组件的实体,因此MovementSystem会自动为它生成一个实体查询以安排其作业。系统可以迭代不同的实体组(例如,在安排多个作业时),因此系统可以有多个查询。实体查询也可以手动创建用于其他目的,但现在我们不需要它们。

实体查询速度快,因为它们缓存了原型块。这些内存块存储具有相同组件集的实体,因此很容易追踪所需实体的位置。有关原型的更多信息,请参阅以下链接:docs.unity3d.com/Packages/com.unity.entities@1.1/manual/concepts-archetypes.html

我们可以在播放模式下使用系统窗口(窗口 | 实体 | 系统)来查看我们的系统找到了多少实体以及所有找到它们的查询。检查系统的实体计数列。此外,在列表中选择系统后,检查器将显示系统查询,并告知每个查询组件。

图 21.19:使用系统窗口和检查器检查系统的查询

使用关系选项卡来查看哪些实体与查询匹配。在以下图像中,你可以看到我们的查询正在找到我们的立方体。

图 21.20:检查与系统查询匹配的实体。

如果你的查询没有匹配到预期的实体,请检查这些实体是否具有所需的组件。很容易忘记将创作组件添加到我们的实体中,甚至忘记将创作游戏对象放入子场景中,错误地将它们留在主场景中。

另一件事要检查的是我们的系统是否正在安排作业,以及系统和作业是否是 Burst 编译的。为此,我们可以执行以下操作:

  1. 打开 Unity 分析器,就像我们在第十八章中看到的那样,性能巫术:使用分析器工具优化你的游戏

  2. 在播放模式下捕获分析数据,并选择任何一帧。

  3. 如果你还没有在时间轴视图中,可以通过点击 CPU 使用模块中的任何位置,点击层次结构按钮以显示选择框,然后点击时间轴来启用该视图。

图 21.21:在分析器的 CPU 模块中启用时间轴模式

  1. 通过使用鼠标滚轮放大来在时间轴中搜索你的作业。你的系统应该在SimulationSystemGroup分析器标记内。请注意,即使我们有成千上万的实体,我们的系统也只安排一个作业来迭代它们,所以它的标记会非常小。

图 21.22:在分析器中找到我们的系统

  1. 检查标记颜色。如果是浅绿色,则表示它是 Burst 编译的。如果是浅蓝色,则不是,这意味着我们没有将BurstCompile属性添加到OnUpdate方法中,或者我们尝试了 Burst 编译非 HPC#代码。注意以下图像中非 Burst 编译版本比 Burst 编译版本慢得多。

图 21.23:非 Burst 编译的系统代码

  1. 在分析器中,点击时间线右上角的三点按钮(不是窗口右上角类似的三点按钮)并启用显示流程事件。这将使我们能够看到系统安排的工作。

图 21.24:启用流程事件

  1. 在时间线中选择我们的系统,并跟随线条到工作。你应该在多个线程中看到它的几个实例,因为每个实例处理不同的实体组(如果足够的话)。记得使用左侧的灰色三角形展开工作线程组,以查看工作详情。

图 21.25:检查系统安排的工作。

  1. 检查作业的标记颜色是否为浅绿色。如果不是,请记住将BurstCompile方法添加到job结构体中(而不是Execute方法)。

在本节中,我们讨论了如何创建和配置一个 DOTS 项目,以及如何创建一个子场景,我们将 GameObject 放置其中以将其转换为实体。我们还创建了一个运行时组件(Velocity),通过 baker(VelocityBaker)添加到转换后的实体中,该组件将作者组件(VelocityAuthoring)转换为运行时组件。我们使用速度值创建了一个系统,该系统安排了一个工作,将速度应用于每个实体的LocalTransform位置,从而移动实体。最后,我们看到了如何调试我们的系统,检查它们创建的查询以在作业中迭代实体,并使用 Unity Profiler 查看系统-作业关系和性能。

当然,我们只是刚刚触及 DOTS 的表面。在下一节中,我们将看到如何创建更有趣的游戏玩法,如移动和生成敌人。

在 DOTS 中创建游戏玩法

我们在 MonoBehaviours 中做的许多事情,比如实例化对象或获取其他对象数据(例如,它们的位置),在 DOTS 中的实现方式相当不同。在本节中,我们将检查如何在 DOTS 中实现这些事情:

  • 使用输入和标签组件移动

  • 使用组件查找创建寻找导弹

  • 使用实体命令缓冲区销毁角色

  • 处理爆发的代码中的错误

  • 使用实体预制体实例化导弹

  • 让摄像机跟随我们的角色

  • 探索其他 DOTS 功能

让我们先讨论如何修改我们的移动代码以响应输入。

使用输入和标签组件移动

让我们从使用输入来移动立方体开始创建游戏玩法。我们需要更改我们的移动作业,以便为其提供输入值。我们将以与通过传递输入值到作业中提供 delta time 相同的方式进行此操作。在下一张图片中,你可以看到所需的变化:

图 21.26:根据我们的输入移动我们的角色

虽然 LocalTransform 有一个 Translate 方法,但它并不是在相对坐标中移动。这就是为什么我们需要使用 RightForward 方法来构建运动向量,这些方法提供了相对于我们对象的这些方向。我们通过在前进方向上添加一个向量,并将其乘以当前输入的 Vertical 值和 DeltaTime 来对位置进行类似的更新,以实现向前移动。

最后,我们修改了我们的系统,以提供如下输入值:

图 21.27:为移动任务提供输入值

图 21.27:为移动任务提供输入值

我们在这里为了简单起见使用了传统的输入系统。你可以自由地使用新的系统。现在,你可以玩游戏并使用输入来控制立方体。我们删除了所有额外的立方体,只留下一个来代表我们的玩家。

需要考虑的是,虽然我们的移动系统会移动带有 Velocity 组件的任何东西,但可能会有其他具有速度的对象以不同的方式移动(例如,通过 AI)。我们需要区分通过输入应用速度的对象和以不同方式应用速度的对象。一种方法是将我们的 velocity 组件重命名为类似“PlayerVelocity”的东西,然后为其他未来的移动方法创建其他速度组件。虽然这会起作用,但这可能不是最佳方法,具体取决于你的游戏需求。假设我们有一个加速区域,它会加速区域内的任何实体。如果我们使用多组件方法,我们的区域将需要考虑我们拥有的所有不同的速度组件,这会使我们的代码变得复杂。

另一种方法可能是给我们的玩家实体添加一个独特的组件来区分它与其他实体,在我们的任务中请求这样的组件。但这样的组件不需要任何数据。我们能否添加一个没有数据的组件?是的!我们称它们为标签组件。正如其名所示,它们作为标记实体以便系统可以轻松找到的一种方式。

如果你阅读了之前提供的原型链接,你应该知道添加标签组件会将实体分割成不同的块。为了避免有太多只有少数实体的块,请避免为单个实体添加过多的唯一标签组件。这种现象被称为原型碎片化。

首先,让我们为我们的 Player 标签创建运行时组件、创作组件和烘焙器。请记住将 PlayerAuthoring 组件添加到我们的玩家实体(立方体)中。

图 21.28:创建玩家标签组件所需的脚本

我们没有创建一个新的Player组件来添加。相反,我们使用了AddComponent<Player> API,指定Player为要添加的组件。一旦添加到玩家实体中,让我们使用这个标签组件在我们的任务中使其仅移动带有该标签的实体。虽然我们可以像之前添加其他组件那样将Player作为任务的execute方法参数,但我们不会读取或写入它上的数据——毕竟它没有数据。相反,另一种让我们的任务迭代具有特定组件的实体的方法是使用WithAll属性,如下一张图片所示:

图 21.29:使任务的自动生成的查询看起来像是在寻找带有 Player 标签的实体。

这样,我们可以重用Velocity组件,并保证每个实体都接收到所需的逻辑。现在,让我们在我们的游戏中添加一个敌人。

使用组件查找创建追踪导弹

在本示例项目中,我们将使玩家躲避不断生成的追踪导弹。为此,我们需要一个具有以下数据的导弹实体:

  • 位置和旋转:由LocalTransform表示。

  • 移动速度:我们已有的相同Velocity

  • 转向速度:我们将创建的新Steering组件。

  • 要跟随的目标实体:我们将创建的新Target组件。

转向速度应该是一个创建起来相当直接组件。只需像创建移动速度组件那样做,但名称不同。至于Target组件,它看起来如下所示:

图 21.30:创建目标组件

首先要注意的是,我们正在使用Entity类型作为Target组件的值。这是 ECS 中拥有目标 GameObject 引用的等效。我们使用实体引用从它获取数据,例如其位置。请注意,编写组件引用 GameObject,因为在编辑时我们仍然使用 GameObject,所以我们引用的是将被转换为实体的PlayerGameObject。最后,烘焙器不仅需要调用GetEntity来获取我们正在转换以添加组件的实体,还需要获取目标 GameObject 的转换后的实体。

现在,你可以在子场景中创建一个名为Missile的球体,并添加VelocitySteeringVelocityTarget的编写组件。请记住通过将玩家 GameObject 从子场景拖动到属性来配置Target组件。

图 21.31:创建导弹的 GameObject

现在,我们可以创建一个系统来将导弹移动到玩家位置。虽然我们可以像对玩家那样创建一个Missile标签组件,但我们已经有足够的组件让转向系统找到导弹。我们将使这个系统迭代具有VelocitySteeringVelocityTarget的实体,鉴于到目前为止我们的游戏中不太可能有非转向实体具有这样一组特定的实体。

这就是转向任务将看起来:

图片

图 21.32:转向任务

首先要注意的是TransformLookup字段,其类型为ComponentLookup。到目前为止,对于我们从正在迭代的实体中读取的数据,我们使用了refin参数作为访问它们的方式,但这次,我们需要访问除任务迭代的实体之外的其他实体的数据,因此我们不能使用这些参数。正如其名所示,查找允许我们从其他实体中获取数据,这意味着我们可以使用它们检索实体的组件。我们正在使用它将目标的变换获取到targetTransform变量中,并在括号中提供目标实体。

另一点要注意的是,我们有LocalToWorld查找。ECS 变换系统使用这个其他组件来存储局部到世界矩阵。这意味着这个组件具有目标实体的世界位置、旋转和缩放,与提供局部值的LocalTransform不同。这个组件允许我们获取世界空间中的实体位置,而不管父变换在哪里。

我们还因为一个棘手的问题而使用了这个组件。我们的任务是修改实体相对于其他实体位置的位置。我们还使用ScheduleParallel来安排任务,创建处理不同实体的多个任务。这可能导致一个任务实例读取另一个任务实例正在写入的实体的位置,这可能会引发多个问题。Unity 可以检测此类情况并警告我们,但在此情况下,这并不必要,因为我们正在通过LocalTransform修改导弹实体的位置时,同时读取LocalToWorld组件以获取目标的位置。这样,我们就不会同时读取和写入相同的组件。实际上,LocalToWorld的数据是由几个 ECS 变换系统之一从LocalTransform值派生出来的。请注意,由于我们不会修改其他实体的LocalToWorld,查找被声明为ReadOnly;我们只是读取它们。修改LocalToWorld没有意义,因为我们之前提到,其值是在渲染实体之前从LocalTransform计算出来的。

我们尽量简要地解释了变换系统,但若想了解更多关于其工作原理的信息,请查看此链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/transforms-concepts.html

最后,我们也检查目标实体是否为空,尽管我们不是使用常规的空,而是使用Entity.Null。结构体不能为空,因为它们不是引用类型;它们始终有一个值。Entity.Null是当实体变量不引用实体时具有的特殊值。转向任务的其余部分使用常规向量数学来应用转向。

最后,安排此任务的系统看起来像这样:

图片

图 21.33:转向系统调度转向工作

我们使用了 GetComponentLookup 方法为我们的工作提供组件查找。我们将 true 作为第一个参数,让它知道我们需要一个只读查找。由于组件查找会引入间接引用,导致缓存未命中,因此使用组件查找的成本很高。始终首选使用工作来获取实体数据,但在这个案例中,它们是获取其他实体数据的唯一方式。

有关缓存未命中的更多信息以及 DOTS 如何处理它们,请查看以下博客文章:blog.innogames.com/unitys-performance-by-default-under-the-hood/

现在,只需玩游戏并观察导弹跟随玩家。考虑设置一个较小的速度但一个较高的转向速度(如 2.0),以便导弹旋转得足够快,接近玩家。

现在我们有了导弹,让我们让它在与玩家足够接近时摧毁玩家。

使用实体命令缓冲区销毁角色

为了让导弹摧毁玩家,我们需要检测它是否足够接近。在常规 GameObject 中,我们可以通过触发器 (OnTriggerEnter) 来实现这一点,但讨论 DOTS 物理包超出了本章的范围。相反,我们将创建一个具有简单距离检查的工作,如下面的图像所示:

图 21.34:ECS 中的距离检查

这个工作与转向工作类似,但进行平方距离检查。现在,棘手的部分是销毁实体。考虑到可能有多个工作并行迭代实体,立即删除实体是不安全的。唯一安全的地方是在主线程上删除、创建或修改实体组件,这些操作也称为结构变化,工作在作业工作者线程上运行。当一个系统在主线程上进行结构变化时,会生成一个同步点,这意味着主线程将被阻塞,直到所有迭代实体的工作完成,这可能导致严重的延迟。另一个问题是,由于我们可以有多个需要结构变化的自定义系统,我们面临引入多个同步点的风险。最佳方法是累积多个结构变化,并让特定的系统执行它们——理想情况下,只有一个。这样,我们减少了同步点的数量,这正是 实体命令缓冲区ECB)系统的作用。

结构变化在性能方面可能会造成成本。有关更多详细信息,请查看此链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/concepts-structural-changes.html

ECB 系统有 API 可以创建 实体命令缓冲区ECBs),允许系统和任务将命令排队到其中,例如实例化或摧毁实体。我们的摧毁系统将使用它们在导弹足够接近时排队玩家摧毁。让我们先修改我们的任务以接收一个 ECB 并使用它。

图片

图 21.35:使用 ECB 摧毁目标实体

我们添加了一个变量来传递 ECB 到我们的任务中,该变量将在创建时由系统提供。然后,我们使用了 ECB 的 DestroyEntity 方法,指定我们要摧毁目标实体。我们提供的零是一种在执行之前对命令进行排序的方式,但在这个情况下,我们不需要任何特定的顺序。

要更好地理解在 ECB 中排序键的需求,请查看此链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/systems-entity-command-buffer-playback.html

然后,我们需要创建一个类似于以下图像的系统:

图片

图 21.36:摧毁接近目标系统

我们从名为 EndSimulationEntityCommandBufferSystem 的 ECB 系统中访问了 Singleton 组件,使用的是 GetSingleton API。此方法允许我们在确定只有一个实例的情况下获取 ECS 组件的唯一实例。你可能想知道:系统可以有组件吗?答案是肯定的。在数据驱动的游戏中,所有数据共享都通过组件进行。每个系统都有一个实体,它们可以向其实例化组件并与其他系统和任务共享数据。鉴于这个 ECB 系统只有一个实例,我们知道 Singleton 组件也只有一个实例。它有一个 CreateCommandBuffer 方法,该方法创建一个 ECB,其命令将在 ECB 系统更新时执行。EndSimulationEntityCommandBufferSystem 系统将在模拟系统结束时执行其 ECB 命令,在渲染我们实体图形的命令之前。这样,我们的任务可以在稍后安全地在主线程中执行结构更改(如摧毁),以及其他使用相同 ECB 系统的系统的命令。

我们需要将我们的系统所在的世界作为此方法的第一个参数提供。世界是一组可以与其他世界并行运行的实体和系统。例如,在 Netcode for Entities 包中,当玩家作为游戏的主机时,我们可以并行运行服务器和客户端世界。在这种情况下,我们只有一个世界。

在这里了解更多关于世界的信息:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/concepts-worlds.html

我们还调用了AsParallelWriter方法,它返回一个 ECB 版本,允许它在多个线程中使用。记住,我们的工作在并行执行多个实例,因此并行写入器版本确保它在我们的工作实例中是安全的。最后,我们将 ECB 提供给工作。

如果你现在按播放并让导弹捕捉到玩家,你会看到它消失了!但是等等!现在控制台正在不断显示错误。虽然这些错误很熟悉,但它们与我们通常在基于 GameObject 的项目中收到的错误不完全一样;它们更加晦涩。这是因为错误发生在突发代码中(简称 Burst-compiled code),这里的错误行为略有不同。让我们探索如何调试它们。

处理突发代码中的错误

在下一张图片中,你可以看到我们在上一步中收到的错误。

图 21.37:突发错误日志

首先是一个熟悉的 C#错误,但堆栈跟踪非常冗长。仔细阅读后,你可以看到每行堆栈跟踪都由一个“#”符号作为前缀,显示被调用方法的名称。在这些符号之后,我们可以在第 5 行找到我们的工作,但信息不多。我们可以通过检查下一行来推断我们的代码失败的位置——在这种情况下,是ComponentLookup调用。鉴于我们的工作只使用一次查找,我们可以推断我们正在尝试获取目标实体的变换,但它已经不存在了。

图 21.38:突发错误堆栈跟踪

为了使堆栈跟踪更清晰,你可以在编辑器中禁用 Burst。这将使错误日志看起来像通常一样,指示错误发生的行。通过取消选中工作 | 突发 | 启用编译选项来禁用它。再次播放以查看现在日志指示错误发生的作业行(第 21 行)。我建议熟悉 Burst 错误日志,因为在构建中,我们无法在没有重新编译我们的玩家后移除BurstCompile属性的情况下回退到非突发代码。此外,在开发过程中禁用 Burst 可以帮助迭代时间,因为编译 Burst 代码比常规代码要长,而在开发过程中,我们并不总是需要峰值性能。

关于这个错误,它发生在当我们接近导弹所指向的实体(玩家)时,该实体被摧毁了。为了解决这个问题,我们可以修改SteeringJob作业的Execute方法,如下面的图片所示:

图 21.39:检查实体是否有 LocalToWorld 组件

我们还需要对DestroyCloseTargetJob作业的Execute方法做同样的处理:

图 21.40:检查实体是否有 LocalToWorld 组件

现在,我们不仅检查目标实体是否为空,还检查它是否使用了 TryGetComponent 查找方法来获取转换。此方法返回一个布尔值,指示请求的实体是否具有请求的组件,并将其返回在输出参数中。如果实体没有组件或实体不存在,该方法将返回 false。这样,如果目标实体没有组件或已被销毁,我们就可以提前退出工作。在 MonoBehaviours 中,仅进行空检查就足够了,但在 DOTS 中则不行。抛开在 MonoBehaviours 中允许这样做的一些具体细节,一个像 target 这样的 Entity 变量在内部只是一个整数 ID。ECS 使用它在查找表中确定实体数据存储的位置(哪个块)。Entity.Null 只是一个无效的 ID,并且由于 Unity 不会在销毁时将所有实体引用设置为无效 ID,因此空检查仍然有效。这样,即使目标实体被销毁,我们 Target 组件中引用的实体仍然有一个有效的 ID。此外,注意我们如何移除了使用查找的行,因为 TryGetMethod 已经在 out 参数中给出了目标的 LocalToWorld 组件,所以没有必要获取组件两次。

如果 TryGetComponent 方法失败,你可以将目标设置为 Entity.Null。鉴于使用查找是性能密集型的,在使用它之前提前退出方法会更好。

现在,让我们实例化更多的导弹,使游戏更有趣。

使用实体 Prefab 实例化导弹

我们可以通过创建具有 Spawner 组件的 spawner 实体来接近导弹生成器——这些实体引用要实例化的 Prefab,并具有要实例化的数量和实例化频率。你可能认为,我们不应该为每个生成器数据创建单独的组件吗?我们可以这样做,但每个在作业和实体查询中使用的组件都会带来性能开销。我们没有理由认为在这个例子中,我们的任何生成器数据将以不同的方式使用,所以这次我们将使用单个组件。当你知道所有数据都将同时被访问时,尽量将数据混合到单个组件中。

Spawner 组件将看起来像以下图片:

图片

图 21.41:Spawner 组件和 baker

注意,我们的Spawner运行时组件有一个Prefab实体字段。以前,我们使用这样的字段来引用导弹的目标,但现在我们使用它来引用要实例化的预制体。我们将创建导弹预制体,并使用GameObject字段在生成器创作组件中引用它。可以使用GetEntity方法再次转换预制体引用。我们还有一个目标引用,因为我们想让生成的导弹跟随玩家。生成器需要向导弹提供目标引用。请记住,在子场景中创建带有spawner组件的spawnerGameObject,并对其进行配置,将player拖到target属性,将导弹Prefab拖到Prefab属性。

由 ECS 组件引用的预制体也将被转换为我们的子场景中的实体,但它们将具有特殊的预制体标签组件。这允许预制体实体存在于子场景中,但会使 ECS 忽略它以防止渲染或更新它。有关预制体烘焙的更多信息,请参阅:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/baking-prefabs.html

现在,我们需要一个系统和一项工作来遍历所有生成器实体,使用 ECB 来实例化导弹:

图片

图 21.42:导弹生成器工作

虽然我们可以逐个实例化我们的预制体,但一次性实例化多个会更好,所以我们创建了一个实体数组并将其提供给Instantiate方法。这个Instantiate方法的重载会根据提供的数组中的元素数量创建实例,并用即将创建的实例的引用(我们正在使用 ECB)填充它。然后,我们遍历这个数组,以 5 个单位间隔的方式设置导弹的位置。我们还通过设置目标组件使它们瞄准玩家。通过用新的组件实例替换来更改组件的值可能会让人困惑,但在 ECS 中这是一种常见的做法。由于组件是结构体,所以这也非常高效。

最后,我们需要系统安排工作:

图片

图 21.43:生成器系统

OnUpdate方法中,唯一的新特性是使用Time.ElapsedTime来获取当前游戏时间。但随后你可以看到新增了一个OnCreate方法,它是系统的Awake方法的等价物。在这里,我们使用RequireForUpdate方法来指定我们需要至少存在一个带有Player组件的实体,以便此系统执行。这样,我们防止在Player被销毁时系统实例化导弹。

现在我们有了我们的生成器系统,让我们让摄像机跟随我们的角色。

让摄像机跟随我们的角色

我们的相机仍然是一个普通的 GameObject,但我们的玩家是一个实体。系统可以在其 OnUpdate 方法中访问 GameObject,前提是该方法是未使用 Burst 编译器编译的。使相机跟随我们的角色的系统将看起来像这样:

图 21.44

图 21.44:相机跟随玩家的系统

如前所述,OnUpdate 不是爆发式(它没有 *BurstCompile* 属性)且不会使用作业,因为它需要访问主相机及其变换,这些是非 HPC# 兼容的引用类型。然后,我们使用了 TryGetSingletonEntity API 来检索我们期望具有 Player 标签的唯一实体的引用。鉴于导弹可以摧毁玩家,我们也使用此 API 来检查玩家实体是否仍然存在。然后,我们使用 GetComponent 方法来获取现有玩家实体的位置。虽然我们可以像之前那样使用组件查找,但鉴于我们直接在系统中编写代码,我们可以使用这种方法作为完成相同任务的快捷方式。

您已经创建了您的第一个 DOTS 游戏!记住,您可以在本书的 Git 仓库中找到本章制作的完整项目。最后,一如既往,关于 DOTS 的学习还有很多,所以让我们简要探索一些最重要的剩余 API。

探索其他 DOTS 功能

本书中有几个主题值得有它们自己的书籍,ECS 也不例外。在本章中挑选最重要的 API 来解释,并尽可能使章节简短,这相当困难,因为有很多东西可以探索。因此,除了我们在信息框中留下的所有有用附加信息外,我还为您留下一份其他值得检查的 DOTS 概念列表:

  • SystemAPI.Query:在不使用作业的情况下迭代系统中实体的一种方式。对于使用托管引用或静态变量的实体迭代代码非常有用。对于简单且执行速度快的事情,可以避免作业调度的成本。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/systems-systemapi-query.html

  • 实体查询:如前所述,作业和其他 ECS API(如 SystemAPI.Query)迭代实体的方式。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/systems-entityquery-intro.html

  • 可启用组件:可以禁用的组件。实体查询将忽略其所需组件被禁用的实体。这对于在无需添加/删除标签组件的情况下切换实体行为非常有用,因为这些操作成本高昂,因为它们需要结构变化。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/components-enableable.html

  • 清理组件:ECS 对应于 OnDestroy 事件的组件。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/components-cleanup.html

  • 动态缓冲区:ECS 中在组件中拥有类似数组数据的方式。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/components-buffer.html

  • Blob 资产:ECS 对应的可脚本化对象。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/blob-assets-intro.html

  • 内容管理:Addressables 的 ECS 版本。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/content-management-intro.html

  • 场景加载:ECS 加载子场景的方式。链接:docs.unity3d.com/Packages/com.unity.entities@1.0/manual/conversion-scene-overview.html

  • 物理和碰撞:物理系统的 ECS 版本。链接:docs.unity3d.com/Packages/com.unity.physics@1.0/manual/index.html

  • 实体网络代码:网络系统的 ECS 版本。链接:docs.unity3d.com/Packages/com.unity.netcode@1.0/manual/index.html

  • DOTS 最佳实践:关于 DOTS 最佳实践的全面指南,旨在最大化您游戏性能并理解 DOTS 内部机制:learn.unity.com/course/dots-best-practices

  • ECS 示例:一个包含多个 ECS 示例和教程的 Git 仓库:github.com/Unity-Technologies/EntityComponentSystemSamples

提供的大多数链接都指向 ECS 文档。建议您通读全文,以便了解您用于创建 ECS 应用程序的所有工具。

在本节中,我们使用了输入和标签组件来移动我们的玩家,以及组件查找来获取其他实体的信息,并探讨了实体命令缓冲区的多种用法,包括实例化和销毁实体,以及如何使我们的 GameObject 相机跟随 ECS 角色。我们还讨论了不同的额外主题,以加深您对 DOTS 的了解。

摘要

在本章中,我们学习了如何通过创建自己的组件和系统来开始使用 DOTS,为简单游戏添加游戏玩法。我们看到了如何使用输入和 DOTS 变换 API 来移动和旋转对象,还看到了如何生成和销毁对象。

DOTS 是一个独立的引擎,运行在 Unity 内部。它的工作方式与以往大相径庭,除了渲染管线等少数系统外,几乎所有的 DOTS 功能都与 GameObject 的对应功能有很大不同。此外,DOTS 仍然相对较新,这意味着它仍然缺少一些功能,并且可能在下一个版本中经历重大变化。

这使得创建新项目存在一定的风险,但另一方面,它所能达到的性能对于大型和雄心勃勃的项目可能是决定性的。这是一项可以彻底改变游戏开发的技术,而且每天都在变得更加强大和稳定。现在是开始学习它并为未来做准备的好时机。

在 Discord 上了解更多

与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接加入社区:

packt.link/unitydev

二维码

posted @ 2025-10-25 10:34  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报