Unity-2022-游戏开发实用指南-全-
Unity 2022 游戏开发实用指南(全)
原文:
zh.annas-archive.org/md5/4aebf6c5c4d9bc7067ea2305f4082c7d译者:飞龙
前言
我仍然记得我害怕告诉父母我要学习游戏开发的那一刻。在当时,在我的地区,这被视为大多数父母眼中的孩子气欲望,一个没有未来的职业,但我足够固执,不在乎,并追随我的梦想。今天,游戏开发已经成为最大的行业之一,其收入超过了电影业。
当然,追随我的梦想比我想的要困难得多。与我有着相同梦想的人迟早会面对这样一个事实:开发游戏是一项困难的任务,需要在不同领域有深入的知识。遗憾的是,大多数人因为这种难度而放弃,但我坚信,有了适当的指导和工具,你可以使你的职业道路更加容易。在我的情况下,帮助我降低学习曲线的是学习使用 Unity。
欢迎阅读这本关于 Unity 2022 的书。在这里,你将学习如何使用最新的 Unity 功能以最简单的方式创建你的第一个视频游戏。Unity 是一个提供强大但易于使用的功能以解决游戏开发中最常见问题的工具,如渲染、动画、物理、音效和效果。我们将使用所有这些功能来创建一个简单但完整的游戏,学习处理 Unity 所需的所有细微差别。
如果你已经阅读过这本书的 2021 年版,你会发现内容不仅已经更新到最新的 Unity 和包版本,而且在 2022 年还引入了新的内容,例如对新的输入系统的介绍。
在这本书结束时,你将能够以使用 Unity 的方式开始深入研究你感兴趣的领域,以建立你的职业生涯或仅仅为了乐趣而创建业余游戏。Unity 是一个多才多艺的工具,可以用于专业和业余项目,并且每天都有越来越多的人在使用它。
值得注意的是,Unity 不仅可以用于创建游戏,还可以用于任何类型的交互式应用程序,从简单的移动应用到复杂的培训或教育应用(称为严肃游戏),使用最新的技术,如增强现实或虚拟现实。因此,即使我们在这里创建游戏,你也在开始一条可以通向许多可能的专业化的学习之路。
这本书适合谁阅读
由于这本书的结构方式,不同背景的人都可以利用整本书或其中的一部分。如果你有基本的面向对象编程知识,但从未创建过游戏,或者从未在 Unity 中创建过游戏,你会发现这本书是游戏开发和 Unity 从基础到高级概念的不错入门。即使你是经验丰富的 Unity 开发者,想要学习如何使用其最新功能,这本书的大部分内容也会对你很有用。
另一方面,如果您没有任何编程知识,您也可以利用这本书,因为大多数章节不需要编程经验就可以学习。这些章节将为您提供强大的技能集,以开始学习 Unity 中的编码,使学习过程比之前更容易,一旦您掌握了编码的基础,您就可以利用本书的脚本章节。此外,随着视觉脚本的引入,如果您更习惯于基于节点的脚本,您将有一个备选语言。
本书涵盖的内容
第一章,创建 Unity 项目,教您如何在您的计算机上安装和设置 Unity,以及如何创建您的第一个项目。
第二章,编辑场景和游戏对象,教您场景和游戏对象的概念,以及 Unity 描述游戏世界组成的 Unity 方式。
第三章,与场景和游戏对象协作,是我们将创建第一个关卡布局的地方,我们将使用地形和 ProBuilder Unity 功能进行原型设计。
第四章,导入和集成资源,是我们将创建第一个关卡布局的地方,我们将使用地形和 ProBuilder Unity 功能进行原型设计。
第五章,C#和视觉脚本简介,是本书的第一章编程内容。我们将学习如何使用 Unity 的方式用 C#创建我们的第一个脚本,然后我们将探索如何使用新的基于节点的编码语言——视觉脚本(Visual Scripting)来完成同样的任务。其余的编程章节将展示如何使用这两种语言编写游戏代码。
第六章,实现移动和生成,教您如何编程对象的移动以及如何生成它们。本章介绍了新的 Unity 输入系统。从现在开始,假设您具备一般的编程知识。
第七章,物理碰撞和健康系统,教您如何配置对象的物理设置以检测两个对象何时发生碰撞并对此做出反应,从而创建一个健康系统,在这种情况下。
第八章,胜负条件,涵盖了检测游戏何时应该结束的方法,包括玩家胜利和失败的情况。
第九章,为构建敌人实现游戏 AI,介绍了如何使用几个 Unity 功能创建基本的 AI,以在我们的游戏中创建具有挑战性的敌人。
第十章,使用 URP 和 Shader Graph 的材质和效果,展示了如何使用最新的 Unity 渲染系统(通用渲染管线,或 URP)以及如何使用 Shader Graph 功能创建效果。
第十一章,使用粒子系统和视觉效果图创建视觉效果,教您如何使用 Unity 的两个主要工具——粒子系统(Particle Systems)和视觉效果图(VFX Graph)——来创建如水火等视觉效果,以及如何编写根据游戏中的情况控制它们的脚本。
第十二章,使用通用渲染管道进行光照,探讨了光照这一概念,它足够重要以至于可以拥有自己的章节。在这里,我们将深化我们对通用渲染管道的了解,特别是其光照功能。
第十三章,使用后处理实现全屏效果,教你如何使用通用渲染管道的后处理功能在你的场景图形上添加一层效果,以获得当今大多数游戏都有的电影效果。
第十四章,声音和音乐集成,涵盖了一个大多数初学者开发者都低估了的话题;在这里,我们将学习如何正确地将声音和音乐添加到我们的游戏中,并考虑其对性能的影响。这也包括如何编写声音脚本。
第十五章,用户界面设计,探讨了用户界面(UI)。在所有向用户传达信息的图形方式中,UI 是最直接的一种。我们将学习如何使用 Unity UI 系统以文本、图像和生命条的形式展示信息,以及如何编写 UI 脚本。
第十六章,使用 UI Toolkit 创建 UI,探讨了 UI Toolkit,它是我们在第十五章,用户界面设计中学到的 Canvas UI 系统的继任者。我们将探索它,以便为 Unity 未来使用基于 HTML 的工具包做好准备。
第十七章,使用 Animator、Cinemachine 和 Timeline 创建动画,将我们带入了迄今为止创建的静态场景之外。在这一章中,我们将开始移动我们的角色并使用最新的 Unity 功能来创建场景,以及如何编写它们的脚本。
第十八章,场景性能优化,讨论了让我们的游戏表现良好并非易事,但确实是发布游戏所必需的。在这里,我们将学习如何分析我们游戏的表现并解决最常见的性能问题。
第十九章,构建项目,教你如何将你的 Unity 项目转换为可执行格式,以便将其分发给其他人并在没有 Unity 安装的情况下运行。
第二十章,Unity 中的增强现实,教你如何使用 Unity 的 AR Foundation 包创建 AR 应用程序,这是使用 Unity 创建 AR 应用程序的最新方法之一。
为了充分利用这本书
你将通过这本书的章节开发一个完整的项目,虽然你可以只阅读章节,但我强烈建议你在阅读本书的过程中练习这个项目中的所有步骤,以获得正确学习这里展示的概念所需的经验。章节设计得可以让你自定义游戏,而不是创建书中展示的确切游戏。然而,请考虑不要偏离主题太远。
项目文件按章节分割,每个文件夹都设计为累积方式,每个文件夹只包含章节中引入的新文件或更改的文件。这意味着,例如,如果一个文件自第一章以来没有更改,您将不会在第二章及以后找到它;那些章节将仅使用第一章中引入的文件。这允许您轻松地看到我们在每个章节中做了什么更改,轻松地识别所需更改,并且如果您由于某种原因无法完成,例如,第三章,您可以直接在第三章的基础上继续第四章的步骤。此外,请注意,第十五章至第十九章将有两个版本的文件,即 C#版本和视觉脚本版本。
| 本书中涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Unity 2022.1 | Windows, macOS X 或 Linux(任何) |
| Visual Studio 2022 Community | Windows 或 macOS X(任何) |
| XCode 13 | macOS X |
虽然我们将看到如何使用 XCode 13,但它对于大多数章节不是必需的。此外,Linux 中还有 Visual Studio 的替代品,如 Visual Studio Code。
如果您正在使用本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Unity-2022-Game-Development-Third-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781803236919_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“将其着色器设置为 Universal Render Pipeline/Particles/Unlit。”
粗体:表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“创建一个新的空 GameObject(GameObject | Create Empty)。”
警告或重要提示显示如下。
小贴士和技巧显示如下。
联系我们
我们始终欢迎读者的反馈。
总体反馈:请将邮件发送至 feedback@packtpub.com,并在邮件主题中提及本书的标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送邮件给我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,点击“提交勘误”,并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Hands-On Unity 2022 Game Development, Third Edition》,我们非常乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在旅途中阅读,但无法携带您的印刷书籍到任何地方吗?
您的电子书购买是否与您选择的设备不兼容?
请放心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地点、任何设备上阅读。从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取这些好处:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781803236919
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。
第一章:创建 Unity 项目
在本章中,我们将学习如何使用 Unity Hub 安装 Unity 并创建一个项目,Unity Hub 是一个管理不同 Unity 安装和项目的工具,还能执行其他任务。Unity Hub 提供了对社区博客、论坛、资源和学习门户的便捷访问;它还管理您的许可证,并在管理不同安装和项目的同时,允许您在打开项目之前更改构建平台。
具体来说,在本章中,我们将探讨以下概念:
-
安装 Unity
-
创建项目
让我们先谈谈如何让 Unity 运行起来。
安装 Unity
我们将从简单但必要的第一步开始:安装 Unity。这似乎是一个直接的第一步,但我们可以讨论正确的安装方法。在本节中,我们将探讨以下概念:
-
Unity 的技术要求
-
Unity 版本控制
-
使用 Unity Hub 安装 Unity
首先,我们将讨论在计算机上运行 Unity 所必需的条件。
Unity 的技术要求
要运行 Unity 2022,您的计算机需要满足以下操作系统要求:
-
如果您使用 Windows,则需要 Windows 7 Service Pack 1 或更高版本、Windows 10 或 Windows 11。Unity 只能在这些系统的 64 位版本上运行;除非您愿意使用 2017.x 之前的 Unity 版本,否则没有 32 位支持,但这超出了本书的范围。
-
对于 Mac,您需要 Big Sur 11.0 来运行编辑器的 Apple Silicon 版本。在其他任何情况下,您都可以从 High Sierra 10.13 或更高版本运行编辑器的 Intel 版本。
-
对于 Linux,您需要确切的 Ubuntu 20.04、18.04 或 CentOS 7。
关于 CPU,以下是要求:
-
您的 CPU 需要支持 64 位
-
您的 CPU 需要支持 SSE2(大多数 CPU 都支持)
-
对于搭载 Apple Silicon 的 Mac,需要 M1 或更高版本
最后,关于显卡,以下是受支持的型号:
-
在 Windows 上,我们需要一款支持 DirectX 10、11 或 12 的显卡(大多数现代 GPU 都支持)
-
在 Mac 上,任何支持 Metal 的 Intel 或 AMD GPU 都足够了
-
在 Linux 上,支持 OpenGL 3.2 或任何更高版本,或来自 Nvidia 和 AMD 的兼容 Vulkan 的显卡
现在我们知道了要求,让我们讨论 Unity 安装管理系统。
Unity 版本控制
Unity 每年发布一个新的主要版本——在撰写本文时,为 2022.1,并在该年度会收到一个带有新功能的更新,在撰写本书时,计划为 2022.2。在年底或下一年初,会发布一个 LTS(长期支持)版本,对于本书的这一版,将是 2022.3,标志着当年版本引擎新功能的结束。之后,下一年的引擎版本将发布,周期重复。
LTS 版本的好处是它们计划每两周更新一次,持续两年,以修复新主要版本的 Unity 中的错误。这就是为什么大多数公司坚持使用引擎的 LTS 版本:因为其稳定性和长期支持。在这本书中,我们将使用 2022.1 来探索引擎的新功能,但在开发商业游戏标题时,请考虑坚持使用 LTS 版本。
考虑到这一点,你可能需要安装几个版本的 Unity,以防你使用不同版本的项目进行工作。你可能想知道为什么你不能为每个项目都使用 Unity 的最新版本,但这样做存在一些问题。
在 Unity 的新版本中,通常会有很多关于引擎工作方式的更改,因此你可能需要重新工作许多游戏的部分来升级它,包括第三方插件。升级整个项目可能需要很多时间,这可能会推迟发布日期。也许你需要更新中包含的特定功能来帮助你。在这种情况下,升级的成本可能是值得的。对于维护和更新多年的项目,开发者习惯于仅更新到编辑器的最新 LTS 版本,尽管这项政策可能因情况而异。
管理使用不同 Unity 版本制作的不同项目,以及安装和更新新的 Unity 版本,过去一直是一件非常麻烦的事情。因此,Unity Hub 被创建出来帮助我们解决这个问题,并且已经成为安装 Unity 的默认方式。尽管如此,安装 Unity 并非必须使用它,但为了简化操作,我们现在将使用它。让我们更深入地了解一下。
使用 Unity Hub 安装 Unity
Unity Hub 是我们在安装 Unity 之前安装的一个小软件。它集中管理你所有的 Unity 项目和安装。你可以从官方 Unity 网站获取它。下载步骤经常变化,但在撰写本书时,你需要执行以下操作:
-
前往 unity.com。
-
点击如图所示的 开始 按钮:

图 1.1:Unity 网站上的“开始”按钮
- 点击 学生和爱好者 选项卡;然后,在 个人 部分,点击如图所示的下方的 开始 按钮:

图 1.2:选择个人/免费许可证
- 滚动到说 1. 下载 Unity Hub 的部分,并根据你的操作系统点击 下载 按钮。对于 Windows,点击 为 Windows 下载,对于 Mac,点击 为 Mac 下载。对于 Linux,有一个 Linux 安装说明 按钮,其中包含有关在该平台上安装的更多信息,但本书不会涵盖 Linux 上的 Unity:

图 1.3:开始下载
-
执行下载的安装程序。
-
按照安装程序的指示操作,这通常意味着一路点击下一步直到结束。
现在我们已经安装了 Unity Hub,必须使用它来安装特定的 Unity 版本。你可以按照以下步骤操作:
-
启动Unity Hub。
-
如果提示安装 Unity 版本和/或创建许可证,请使用相应的跳过按钮(这可能会根据 Unity Hub 版本而有所不同)跳过这些步骤。这种方式安装 Unity 和许可证只适用于第一次运行 Unity Hub,但我们将学习第一次之后如何操作。
-
通过点击窗口左上角的“人”图标并选择登录来登录您的账户:

图 1.4:登录 Unity Hub
- 在这里,如果你还没有创建 Unity 账户,也可以选择创建一个,如以下截图所示,点击登录提示中标记为创建一个的链接:

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

图 1.6:Unity Hub 窗口
-
点击安装按钮,检查是否列出了Unity 2022。
-
如果没有,请点击右上角的安装编辑器按钮。这将显示可以从这里安装的 Unity 版本列表:

图 1.7:可安装的 Unity 版本
-
你会看到这里有三个标签页。官方发布版包含已发布的每个主要版本的最新版本。预发布版包含 Unity 的 alpha 和 beta 版本,因此你可以参与这些项目并在它们正式发布之前测试新功能。存档包含指向Unity 下载存档的链接,其中包含发布的每个 Unity 版本。例如,撰写本书时的官方版本是 2022.1.20,但项目正在开发 2022.1.14,因此你可以从存档中安装正确的版本。
-
在官方发布版标签页中找到 Unity 2022.1。
-
点击 Unity 2022.1.XXf1 右侧的安装按钮,其中 XX 将根据最新可用版本而变化。在撰写本书时,我们使用的是 2022.1.14f1。你可能需要向下滚动以找到这个版本。如果不存在,请安装可用的最新 2022 版本(例如,2022.2.XX 或 2022.3.XX)。如果发现书中图像与书中内容差异太大,请考虑在存档中查找 Unity 2022.1.14。
-
将会弹出一个模块选择窗口。请确保勾选了Visual Studio功能。虽然这个程序在 Unity 中工作不是必需的,但本书后面我们会用到它。如果你已经安装了 C# IDE,可以自由跳过这一步。
-
现在,点击继续按钮:

图 1.8:选择 Visual Studio
- 接受 Visual Studio 的条款和条件,然后点击安装:

图 1.9:接受 Visual Studio 的条款和条件
需要注意的是,Visual Studio 是我们将在第五章,“使用 C#和可视化脚本介绍脚本”,中使用的程序来创建我们的代码。目前我们不需要其他 Unity 功能,但如果你需要,稍后可以回来安装它们。
- 你将看到所选的 Unity 版本正在下载和安装。等待这个过程完成。如果你看不到它,点击下载按钮重新打开它:

图 1.10:当前活跃的 Unity Hub 下载
- 如果你决定安装 Visual Studio,Unity 安装完成后,Visual Studio 安装程序将自动执行。它将下载一个安装程序,该安装程序将下载并安装 Visual Studio Community:

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

图 1.12:可用的 Unity 版本
现在,在使用 Unity 之前,我们需要通过以下步骤获取和安装免费许可证,使其工作:
- 在 Unity Hub 右上角点击管理许可证按钮。如果你看不到它,点击左上角的账户图标,然后在那里点击管理许可证:

图 1.13:获取免费许可证需要按下的管理许可证按钮
- 在许可证列表窗口中点击添加按钮:

图 1.14:许可证列表窗口的添加按钮
- 点击获取免费个人许可证按钮:

图 1.15:获取免费个人许可证的选项
- 如果你同意,请点击同意并获取个人版许可证按钮阅读并接受条款和条件:

图 1.16:接受条款和条件的按钮
记住,前面的步骤在新版本的 Unity Hub 中可能会有所不同,所以只需尽量遵循 Unity 设计的流程——大多数情况下,它是直观的。
现在是时候使用 Unity 创建一个项目了。
创建项目
现在我们已经安装了 Unity,我们可以开始创建我们的游戏。为此,我们首先需要创建一个项目,这基本上是一个包含你的游戏将包含的所有文件的文件夹。这些文件被称为资产,它们有不同的类型,例如图像、音频、3D 模型、脚本文件等等。在本节中,我们将了解如何管理项目,讨论以下概念:
-
创建项目
-
项目结构
让我们先学习如何创建一个空白项目以开始开发我们的项目。
创建项目
与 Unity 安装一样,我们将使用 Unity Hub 来管理项目。我们需要遵循以下步骤来创建一个:
- 打开 Unity Hub 并点击项目按钮,然后点击新建项目:

图 1.17:在 Unity Hub 中创建新项目
-
选择3D (URP)模板,因为我们将会创建一个使用简单图形的 3D 游戏,并准备在 Unity 能够运行的任何设备上运行,所以通用渲染管线(或URP)是更好的选择。在第十章,使用 URP 和 Shader Graph 的材质和效果,我们将详细讨论为什么。
-
如果你看到一个下载模板按钮,请点击它;如果没有,这意味着你已经有这个模板了:

图 1.18:下载 3D URP 模板
- 选择一个项目名称和一个位置,然后点击创建项目:

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

图 1.20:Unity 编辑器窗口
- 关闭窗口,然后返回 Unity Hub 并从列表中选择项目以再次打开它:

图 1.21:重新打开项目
现在我们已经创建了项目,让我们来探索它的结构。
项目结构
我们刚刚打开了 Unity,但直到下一章我们才不会开始使用它。现在,是时候看看项目文件夹结构是如何组成的了。为了做到这一点,我们需要打开我们创建项目的文件夹。如果你不记得在哪里,你可以这样做:
-
右键点击位于编辑器底部项目面板中的Assets文件夹。
-
点击在资源管理器中显示选项(如果你使用的是 Mac,该选项称为在 Finder 中显示)。以下截图说明了这一点:

图 1.22:在资源管理器中打开项目文件夹
- 然后,你将看到一个类似于以下文件夹结构(某些文件或文件夹可能有所不同):

图 1.23:Unity 项目文件夹结构
如果你想要将这个项目移动到另一台 PC 或者发送给同事,你只需将这些文件压缩成一个 ZIP 文件并发送给他们,但并不是所有文件夹在所有时候都是必要的。重要的文件夹是Assets、Packages和ProjectSettings。Assets将保存我们为游戏创建和使用的所有文件,所以这是必须的。我们还将配置不同的 Unity 系统以调整引擎以适应我们的游戏;所有与此相关的设置都在ProjectSettings和UserSettings文件夹中。最后,我们将安装不同的 Unity 模块或包以扩展其功能,所以Packages文件夹将保存我们正在使用的那些。
如果你需要将项目移动到其他地方或添加到任何版本控制系统,没有必要复制其余的文件夹,但至少让我们讨论一下Library文件夹是什么,特别是考虑到它通常非常大。Unity 需要将我们将要使用的文件转换为它自己的格式以便操作,例如音频和图形。Unity 支持MPEG Audio Layer 3(MP3)、Waveform Audio File Format(WAV)、Portable Network Graphics(PNG)和Joint Photographic Experts Group(JPG)文件(等等),但在使用它们之前,它们需要被转换为 Unity 的内部格式,这个过程称为导入资源。那些转换后的文件将位于Library文件夹中。如果你没有复制那个文件夹,Unity 将简单地从Assets文件夹中取原始文件并完全重新创建Library文件夹。这个过程可能需要时间,项目越大,所需时间越长。
请记住,当你正在处理项目时,你希望保留 Unity 创建的所有文件夹,所以在处理项目时不要删除任何文件夹,但如果需要移动整个项目,你现在知道你需要带哪些东西了。
摘要
在本章中,我们回顾了 Unity 版本控制系统的工作方式。我们还看到了如何使用 Unity Hub 安装和管理不同的 Unity 版本。最后,我们使用相同的工具创建了多个项目。我们将大量使用 Unity Hub,所以了解如何最初使用它很重要。现在,我们已经准备好深入 Unity 编辑器了。
在下一章中,我们将开始学习基本的 Unity 工具来创建我们的第一个级别原型。
加入我们的 Discord 频道!
与其他用户、Unity 游戏开发专家和作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,等等。
扫描二维码或访问链接加入社区。

packt.link/handsonunity22
第二章:编辑场景和游戏对象
在本章中,我们将学习一些 Unity 的基础知识,以便编辑项目,并学习如何使用几个 Unity 编辑器窗口来操作我们的第一个场景及其对象。我们还将了解一个对象,或称为 GameObject,是如何创建和组成的,以及如何使用层级和预制件来管理包含多个对象的复杂场景。最后,我们将回顾如何正确保存所有工作以便稍后继续工作。
具体来说,在本章中,我们将探讨以下概念:
-
操作场景
-
GameObjects 和组件
-
对象层级
-
预制件
-
保存场景和项目
操作场景
场景是我们项目中几种类型的文件(也称为资产)之一。根据项目的类型,“场景”可以用于不同的目的,但最常见的情况是将游戏分成整个部分,最常见的是以下几种:
-
主菜单
-
第 1 级,第 2 级,第 3 级等。
-
胜利画面和失败画面
-
启动画面和加载画面
在本节中,我们将介绍与场景相关的以下概念:
-
场景的目的
-
场景视图
-
将我们的第一个 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 对象、2D 对象、效果等,如下截图所示:

图 2.5:创建一个立方体
在3D 对象类别下,我们将看到几个 3D 基本形状,如立方体、球体、圆柱体等,虽然使用它们不如使用漂亮的下载 3D 模型那么令人兴奋,但请记住,我们目前只是在原型化我们的关卡。这被称为灰盒测试,意味着我们将使用大量的原型基本形状来建模我们的关卡,以便我们可以快速测试它,看看我们的想法是否足够好,可以开始将其转换为最终版本的工作。
我建议您选择立方体对象开始,因为它是一个多功能的形状,可以代表许多对象。所以,现在我们有一个可以编辑的场景对象,我们需要学习使用场景视图在场景中导航的第一件事。
在场景视图中导航
为了操作场景,我们需要学习如何在其中移动以从不同角度查看结果。有几种导航场景的方法,所以让我们从最常见的一种开始,即第一人称视角。这种视角允许您使用类似第一人称射击游戏的导航方式在场景中移动,使用鼠标和WASD键。要这样导航,您需要按下并保持鼠标右键,在此过程中,您可以:
-
将鼠标移动到当前相机位置周围旋转相机
-
按下WASD键来移动相机位置,始终保持右键点击
-
您也可以按Shift键来加快移动速度
-
按下Q和E键来上下移动
另一种常见的移动方式是点击一个对象来选择它(选中的对象将有一个橙色的轮廓),然后按下F键来聚焦于它,使场景视图相机立即移动到一个可以更仔细观察该对象的位置。之后,我们可以在 Windows 上按下并保持左Alt键,或在 Mac 上按下Option键,同时与左鼠标点击一起,最终开始移动鼠标并“环绕”对象。这将允许您从不同的角度查看聚焦的对象,以检查它的每个部分是否都正确放置,如下面的截图所示:

图 2.6:选择对象
现在我们可以在场景中自由移动,我们可以开始使用场景视图来操作游戏对象。
操作游戏对象
场景视图的另一个用途是操纵对象的位置。为了做到这一点,我们首先需要选择一个对象,然后按下场景视图左上角的变换工具。一旦选择了对象,您也可以按键盘上的Y键来完成同样的操作:

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

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

图 2.9:轴辅助工具
计算机图形学使用经典的 3D笛卡尔坐标系来表示对象的定位。红色与对象的x轴相关联,绿色与y轴相关联,蓝色与z轴相关联。
但每个轴代表什么意思呢?如果你习惯了另一个 3D 创作程序,这可能会不同,但在 Unity 中,z轴代表前向向量,这意味着箭头指向对象的正面;x轴是右向量,而y轴代表向上向量。
这些方向被称为局部坐标,这是因为每个对象都可以以不同的方式旋转,这意味着每个对象可以根据其方向将其前向、向上和向右的向量指向其他地方。当在章节的“对象层次结构”部分后面使用时,局部坐标将更有意义,所以请耐心等待,但现在讨论全局坐标是值得的。想法是有一个单一的起点(零点)和一组共同的向前、向右和向上轴,这些轴在场景中是通用的。这样,当我们说对象的全局位置为5,0,0时,我们知道我们指的是从全局零位置沿全局x轴延伸 5 米的坐标。全局轴就是你在前面提到的右上角轴辅助工具中看到的那一组。
为了确保我们正在使用局部坐标,即我们将沿着对象的局部轴移动对象,请确保在场景视图中激活了局部模式,如下面的截图所示:

图 2.10:切换轴心点和局部坐标
如果正确的按钮显示的是全局而不是本地,只需点击它,然后从下拉选项中选择本地。顺便说一句,尽量保持左侧按钮为枢轴。如果它显示为中心,点击并选择枢轴。物体的枢轴不一定是它的中心,这完全取决于我们使用的 3D 模型,其作者将指定物体旋转中心的位置。例如,一辆车可能其枢轴位于后轮中间,因此当我们旋转时,它将尊重真实汽车的旋转中心。基于物体的枢轴进行编辑将简化我们理解如何在第六章,实现移动和生成中通过 C#脚本旋转的方式。现在,既然我们已经启用了本地坐标,你应该能看到图 2.8中看到的立方形箭头;我们稍后将会使用它们来缩放立方体。
我知道——我们正在编辑一个立方体,所以没有明显的正面或右侧,但当你与真实的 3D 模型如汽车和角色一起工作时,它们肯定会有那些面,并且它们必须与那些轴正确对齐。如果将来你将一辆汽车导入 Unity,并且汽车的前端指向x轴,你需要将模型沿z轴对齐,因为我们将要创建的用于移动对象的代码将依赖于这个约定(但让我们稍后再说)。
现在,让我们使用这个变换辅助工具来通过围绕它的三个彩色圆圈旋转物体。例如,如果你点击并拖动红色圆圈,你将沿着x轴旋转物体。如果你想水平旋转物体,根据我们之前讨论的颜色编码,你可能会选择x轴——用于水平移动的那个轴——但遗憾的是,这是错误的。看待旋转的一个好方法是像摩托车油门一样:你需要拿起它并转动它。如果你这样旋转x轴,你将使物体上下旋转。所以,为了水平旋转,你需要使用绿色圆圈或y轴。这个过程在下图中得到了说明:

图 2.11:旋转物体
最后,我们有缩放,我们有两种方法可以实现这一点,其中一种是通过图 2.8中显示的变换辅助工具中心的灰色立方体。这允许我们通过点击并拖动该立方体来改变物体的大小。现在,由于我们想要原型化一个简单的关卡,有时我们想要拉伸立方体来创建,例如,一列柱子或一个平坦的地板,这就是第二种方法发挥作用的地方。
如果你点击并拖动位于平移箭头前面的彩色立方体,而不是中心的那灰色立方体,你会看到我们的立方体是如何沿着这些轴拉伸的,这允许你改变物体的形状。如果你没有看到那些立方形箭头,请记住按照本节前面所述启用本地坐标。
拉伸的过程在下一张屏幕截图中进行说明:

图 2.12:缩放对象
记住,如果你想要,你也可以使用中间的灰色立方体同时缩放所有轴,这被称为均匀缩放,与我们在变换gizmo 中遇到的相同的灰色立方体。
最后,在这里需要考虑的是,由于它们最初的设计方式,几个对象可以有相同的缩放值,但大小不同。缩放是我们可以应用于对象原始大小的乘数,因此一个建筑和一个汽车都具有缩放 1 是完全有意义的;一个相对于另一个的相对大小看起来是正确的。这里的主要启示是,缩放不是大小,而是一种乘以它的方式。
考虑到在许多情况下缩放对象通常是一种不良做法。在你的场景最终版本中,你将使用适当大小和比例的模型,并且它们将以模块化的方式设计,以便你可以将它们一个接一个地插入。如果你对它们进行缩放,可能会发生一些不好的事情,比如纹理被拉伸并变得像素化,以及不再能够正确插入的模块。这个规则有一些例外,比如在森林中放置大量相同树木的实例,并稍微改变其比例以模拟变化。此外,在灰色盒子的案例中,将立方体进行缩放以创建地板、墙壁、天花板、柱子等是完全可行的,因为最终,那些立方体将被真实的 3D 模型所取代。
这里有一个挑战!创建一个由地板、三面普通墙壁和一面有门洞的第四面墙壁(三个立方体)组成的房间,不需要屋顶。在下一张图片中,你可以看到它应该看起来是什么样子:

图 2.13:房间任务完成
现在我们能够编辑对象的位置了,让我们看看我们如何编辑它的其他所有方面。
GameObjects 和 components
我们讨论了我们的项目由资产(项目的文件)组成,以及一个场景(它是一种特定的资产)由 GameObject 组成;那么,我们如何创建一个对象呢?通过组件的组合。
在本节中,我们将介绍与组件相关的以下概念:
-
理解组件
-
操作组件
让我们先来讨论一下什么是组件。
理解组件
组件是构成 GameObject 的几个部分之一;每个组件负责对象的不同功能。Unity 已经包含了一些解决不同任务的组件,例如播放声音、渲染网格、应用物理等;然而,尽管 Unity 有大量的组件,我们最终迟早需要创建自定义组件。
在下一张图片中,你可以看到当我们选择 GameObject 时 Unity 向我们展示的内容:

图 2.14:检查器面板
在前面的屏幕截图中,我们可以看到检查器面板。如果我们现在需要猜测它做什么,我们可以说它显示了通过层次结构或场景视图选择的对象的所有属性,并允许我们配置这些选项以更改对象的行为(例如,位置和旋转,是否投射阴影等)。这是真的,但我们遗漏了一个关键元素:这些属性不属于对象;它们属于对象的组件。我们可以在一组属性之前看到一些加粗的标题,例如变换和盒子碰撞器等。这些都是对象的组件。
在这种情况下,我们的对象有一个变换、一个网格过滤器、一个网格渲染器和一个盒子碰撞器组件,因此让我们回顾一下这些组件。
变换仅包含对象的位置、旋转和缩放,它本身并不做任何事情——它只是游戏中的一个点——但当我们向对象添加组件时,这个位置开始具有更多的意义。这是因为一些组件将与变换和其他组件交互,每个组件都会影响另一个组件。
这方面的一个例子是网格过滤器和网格渲染器,这两个组件都负责渲染 3D 模型。网格渲染器将渲染由网格过滤器指定的 3D 模型,也称为网格,在变换组件指定的位置进行渲染,因此网格渲染器需要从其他组件获取数据,没有它们无法工作。
另一个例子是盒子碰撞器。这表示对象的物理形状,因此当物理计算对象之间的碰撞时,它会检查该形状是否根据变换组件指定的位置与其他形状发生碰撞。
我们将在本书的后面部分探讨渲染和物理,但本节的要点是 GameObject 是一个组件的集合,每个组件都为我们的对象添加特定的行为,并且每个组件都与其他组件交互以完成所需的任务。为了进一步强化这一点,让我们看看如何将一个立方体转换为在物理作用下会下落的球体。
操作组件
编辑对象组件的工具是检查器。它不仅允许我们更改组件的属性,还让我们可以添加和删除组件。在这种情况下,我们想要将一个立方体转换为球体,因此我们需要更改这些组件的几个方面。
我们可以先从更改对象的视觉形状开始,因此我们需要更改渲染的模型或网格。指定要渲染的网格的组件是网格过滤器组件。如果我们查看它,我们可以看到一个名为网格的属性,它说立方体,在其右侧有一个小圆圈和一个小点:

图 2.15:网格过滤器组件
如果你没有看到特定的属性,例如我们刚才提到的网格,尝试点击组件名称左侧的三角形。这样做将会展开和折叠所有组件的属性。
如果我们点击带有圆圈和点的按钮,即网格属性右侧的按钮,将弹出选择网格窗口,允许我们选择几个网格选项。在这种情况下,选择球体网格。将来,我们将向我们的项目添加更多 3D 模型,这样窗口将会有更多选项。
网格选择器在以下屏幕截图中显示:

图 2.16:网格选择器
好的——现在对象看起来像一个球体,但它会像球体一样表现吗?让我们来找出答案。为了做到这一点,我们可以在我们的球体上添加一个名为刚体的组件,这将给它添加物理属性。我们将在第七章物理碰撞和健康系统中更详细地讨论刚体和物理,但现在让我们专注于基础知识。
为了做到这一点,我们需要点击检查器底部的添加组件按钮。它将显示一个组件选择器窗口,其中包含许多类别;在这种情况下,我们需要点击物理类别。窗口将显示所有物理组件,在那里我们可以找到刚体。另一个选项是在窗口顶部的搜索框中输入刚体。以下屏幕截图说明了如何添加组件:

图 2.17:添加组件
如果你点击编辑器顶部中间的播放按钮,你可以使用游戏面板测试你的球体物理。当你点击播放时,该面板将自动聚焦,并显示玩家将如何看到游戏。回放控制如图所示:

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

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

图 2.20:球体和斜坡对象
如果你现在点击播放,你会看到球体与我们的斜坡发生碰撞,但方式很奇怪。它看起来像是在弹跳,但实际上并不是这样。如果你展开球体的盒子碰撞器组件,你会看到即使我们的对象看起来像一个球体,绿色的盒子辅助工具显示我们的球体在物理世界中实际上是一个盒子,如下面的截图所示:

图 2.21:具有球体图形和盒子碰撞器的对象
现在,显卡(GPU)可以处理渲染高度详细的三维模型(具有高多边形计数的模型),但物理系统是在中央处理器(CPU)中执行的,并且它需要进行复杂的计算以检测碰撞。为了在我们的游戏中获得良好的性能,它至少需要以每秒 30帧(FPS)的速度运行,这是行业接受的最低标准,以提供流畅的体验。物理系统考虑到了这一点,因此它使用简化的碰撞形状,这些形状可能与玩家在屏幕上看到的实际形状不同。
正因如此,我们才将网格过滤器和不同类型的碰撞器组件分开——一个处理视觉形状,另一个处理物理形状。
再次,本节的想法不是深入研究那些 Unity 系统,所以我们现在就继续前进。我们如何解决我们的球体实际上是一个盒子的问题?简单:通过修改我们的组件!在这种情况下,已经存在于我们的立方体 GameObject 中的盒子碰撞器组件可以仅表示一个物理形状,与网格过滤器不同,它支持任何渲染形状。因此,首先,我们需要通过右键单击组件的标题并选择移除组件选项来将其移除,如下面的截图所示:

图 2.22:移除组件
现在,我们再次使用添加组件菜单来选择一个物理组件,这次选择球体碰撞组件。如果你查看物理组件,你会看到其他可以用来表示其他形状的碰撞器类型,但我们将在第七章物理碰撞和健康系统中稍后讨论它们。以下截图显示了球体碰撞组件:

图 2.23:添加球体碰撞组件
因此,如果你现在点击播放,你会看到我们的球体不仅看起来像一个球体,而且表现得像一个球体。记住:本书这一部分的主要思想是理解在 Unity 中,你可以通过添加、删除和修改组件来创建任何你想要的对象,而且我们将在整本书中做很多这样的事情。
现在,创建对象所需的不仅仅是组件。复杂对象可能由几个子对象组成,所以让我们看看这是如何工作的。
对象层次结构
一些复杂对象可能需要分解成子对象,每个子对象都有自己的组件。这些子对象需要以某种方式附着到主对象上,并协同工作以创建必要的对象行为。
在本节中,我们将介绍与对象相关的以下概念:
-
对象的关联
-
可能的用途
让我们先来了解如何创建对象之间的父子关系。
对象的关联
关联是指将一个对象作为另一个对象的子对象,这意味着这些对象将相互关联。发生的一种关系是变换关系,意味着子对象将受到父对象变换的影响。简单来说,子对象将跟随父对象,就像它被附着在上面一样。例如,想象一个头上戴着帽子的玩家。帽子可以是玩家头部的子对象,使帽子在它们附着时跟随头部。
为了尝试这个功能,让我们创建一个代表敌人的胶囊和一个代表敌人武器的立方体。记住,为了做到这一点,你可以使用GameObject | 3D Object | Capsule和Cube选项,然后使用Transform工具来修改它们。以下截图显示了胶囊和立方体的示例:

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

图 2.25:将立方体武器与胶囊角色关联
现在,如果你移动敌人,你会看到枪随着移动、旋转和缩放。所以,基本上,枪的变换也具有敌人变换组件的效果。
现在我们已经进行了一些基本的育儿工作,让我们来探索其他可能的用途。
可能的用途
除了创建复杂对象之外,关联还有一些其他用途。它的另一个常见用途是组织项目层次结构。目前,我们的场景很简单,但随着时间的推移,它将增长,因此跟踪所有对象将变得困难。为了避免这种情况,我们可以创建空的 GameObject(在GameObject | Create Empty),它们只包含变换组件,作为容器使用,将对象放入其中以组织场景。请谨慎使用,因为过度使用会有性能成本。通常,在组织场景时,有一到两个层次的关联是合适的,但超过这个层次可能会影响性能。考虑到你可以——并且将会——为创建复杂对象进行更深层次的关联;所提出的限制只是针对场景组织。
为了继续改进我们之前的例子,在场景周围复制敌人几次,创建一个空的 GameObject 并将其命名为Enemies,然后将所有敌人拖入其中,使其充当容器。这在上面的屏幕截图中有说明。

图 2.26:将敌人分组到父对象中
父对象的一个常见用法是改变对象的旋转中心(或中心)。目前,如果我们尝试使用变换工具旋转我们的枪,它将围绕其中心旋转,因为那个立方体的创建者决定将中心放在那里。通常情况下,这是可以的,但让我们考虑这样一个情况,我们需要让武器瞄准敌人正在看的点。在这种情况下,我们需要围绕武器手柄旋转武器;因此,对于这个立方体武器来说,它将是离敌人最近的一端。这里的问题是,我们无法改变对象的中心,所以一个解决方案是创建另一个具有不同中心的“武器”3D 模型或网格,如果我们考虑其他可能的游戏玩法要求,如旋转武器拾取,这将导致大量武器的重复版本。我们可以通过使用父对象关系轻松解决这个问题。
策略是创建一个空的 GameObject 并将其放置在我们想要对象新旋转中心的位置。之后,我们可以简单地拖动我们的武器到这个空的 GameObject 中,从现在起,将这个空对象视为实际的武器。
如果你旋转或缩放这个武器容器,你会看到武器网格将应用这些变换到这个容器上,所以我们可以说武器的旋转中心已经改变(实际上并没有,但我们的容器模拟了这种变化)。这个过程在下面的屏幕截图中有说明:

图 2.27:改变武器旋转中心
现在,让我们继续探讨使用预制件管理 GameObject 的不同方法。
预制件
在之前的例子中,我们在场景周围创建了敌人的许多副本,但在这样做的同时,我们创建了一个新的问题。让我们想象我们需要更改我们的敌人并为其添加一个刚体组件,但由于我们有相同对象的多个副本,我们需要逐个添加相同的组件到所有副本上。也许以后,我们需要更改每个敌人的质量,所以同样,我们需要逐一检查每个敌人并做出更改,从这里我们可以开始看到一种模式。一个解决方案可能是使用Ctrl键(在 Mac 上为Command)选择所有敌人并一次性修改它们,但如果我们在其他场景中有敌人的副本,这个解决方案将没有任何用处。所以,这就是预制件发挥作用的地方。
在本节中,我们将介绍与预制件相关的以下概念:
-
创建预制件
-
预制件实例关系
-
预制件变体
让我们从讨论如何创建和使用预制件开始。
创建预制件
预制件是 Unity 的一个工具,它允许我们将自定义对象,如我们的敌人,转换为一个定义它们如何创建的资产。我们可以使用它们轻松地创建自定义对象的副本,而无需再次创建其组件和子对象。
为了创建预制件,我们可以简单地从层次窗口将我们的自定义对象拖动到项目窗口中,完成之后你将在项目文件中看到一个新资产。项目窗口是你可以导航和探索所有项目文件的地方;因此,在这种情况下,我们的预制件是我们创建的第一个资产。现在,你可以简单地从项目窗口中将预制件拖动到场景中,以便轻松创建新的预制件副本,如以下截图所示:

图 2.28:创建预制件
现在,我们这里有一个小问题。如果你注意查看层次窗口,你会看到原始的预制件对象以及所有带有蓝色名称的新副本,而之前创建的敌人将会有黑色名称。名称中的蓝色表示该对象是预制件的实例,意味着该对象是基于预制件创建的。我们可以选择那些带有蓝色名称的对象,然后点击检查器中的选择按钮来选择创建该对象的原始预制件。这在上面的截图中有说明:

图 2.29:在层次窗口中检测预制件
因此,这里的问题是之前的预制件副本不是我们刚刚创建的预制件的实例,而且很遗憾没有方法将它们连接到它。所以,为了实现这一点,我们需要简单地销毁旧副本,并用带有预制件的副本替换它们。起初,不是所有副本都是实例似乎没有问题,但在本章的下一节中,我们将探讨预制件与其实例之间的关系时,这将会成为一个问题。
预制件-实例关系
当将预制件拖动到场景中创建 GameObject 时,预制件的实例将有一个与之绑定的引用,这有助于在预制件和实例之间轻松地回滚和应用更改。如果你对预制件进行一些修改,这些更改将自动应用到项目中的所有场景中的所有实例上,因此我们可以轻松地创建预制件的第一版,在整个项目中使用它,然后进行实验。
为了练习这个,假设我们想要给敌人添加一个刚体组件,以便它们可以下落。为了做到这一点,我们可以在项目面板中双击预制件文件,我们将进入预制件编辑模式,在那里我们可以独立于场景的其余部分编辑预制件。
在这里,我们可以简单地取预制件根对象(在我们的例子中是敌人)并为其添加刚体组件。之后,我们只需点击场景窗口左上角的场景按钮,就可以回到我们正在编辑的场景,现在我们可以看到所有敌人的预制实例都有一个刚体组件,如以下屏幕截图所示:

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

图 2.31:使用重力作为覆盖被突出显示
覆盖优先于预制件,所以如果我们更改原始预制件的缩放比例,具有缩放覆盖的那个实例不会改变,保持其自己的缩放版本,如以下屏幕截图所示:

图 2.32:具有缩放覆盖的单个预制实例
我们可以通过在层次结构中选择预制实例(场景中的,在预制件编辑模式外)后,在检查器中使用覆盖下拉菜单轻松地定位实例的所有覆盖,找到我们的对象所做的所有更改。它不仅允许我们查看所有覆盖,还可以撤销我们不需要的覆盖,并应用我们想要的覆盖。比如说我们后悔了那个特定预制件缺少重力的设置——没问题!我们只需定位覆盖,并在点击具有覆盖的组件后使用撤销按钮来撤销它。这个过程在以下屏幕截图中得到了说明:

图 2.33:撤销单个覆盖
此外,让我们想象我们真的很喜欢那个实例的新缩放比例,所以想让所有实例都具有那个缩放比例——太好了!我们可以简单地选择特定的覆盖,点击应用按钮,然后选择应用到预制件选项;现在,所有实例都将具有那个缩放比例(除了具有覆盖的实例),如以下屏幕截图所示:

图 2.34:应用按钮
此外,我们还有全部还原和全部应用按钮,但使用时要小心,因为你可以很容易地还原和应用你未意识到的更改。
因此,正如你所看到的,预制体是 Unity 中一个非常有用的工具,可以跟踪所有类似的对象,并将更改应用到所有对象上,同时还可以有少量变体的特定实例。谈到变体,还有其他情况下你将想要有具有相同变体集的多个预制体实例——例如,飞行敌人和地面敌人——但如果你考虑一下,我们会遇到我们之前没有使用预制体时遇到的问题,所以我们需要逐个手动更新这些变体版本。
在这里,我们有两种选择:一种是为拥有另一个带有该变体的版本而创建一个全新的预制体。这会导致一个问题,即如果我们想让所有类型的敌人都经历变化,我们需要手动将更改应用到每个可能的预制体上。第二种选择是创建一个预制体变体。让我们回顾一下后者。
预制体变体
预制体变体是基于现有预制体创建的新预制体,因此新预制体继承了基础预制体的特性。这意味着我们的新预制体可以与基础预制体有所不同,但它们共有的特性仍然相连。
为了说明这一点,让我们创建一个可以飞行的敌人预制体的变体:飞行敌人预制体。为了做到这一点,我们可以在层次窗口中选择一个现有的敌人预制体实例,将其命名为飞行敌人,并将其再次拖动到项目窗口中,这次我们会看到一个提示,询问我们想要创建哪种类型的预制体。这次,我们需要选择预制体变体,如下面的截图所示:

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

图 2.36:预制体变体实例
现在,假设你想要给所有类型的敌人都加上一顶帽子。我们可以简单地通过双击基础敌人预制体进入预制体编辑模式,并添加一个立方体作为帽子。现在,我们将看到这个更改应用到所有敌人身上,因为记住:飞行敌人预制体是基础敌人预制体的一个变体,这意味着它将继承所有这些更改。
到目前为止,我们已经创建了大量的内容,但如果我们的电脑因为某种原因关闭,我们肯定会丢失所有内容,所以让我们看看我们如何可以保存我们的进度。
保存场景和项目
就像任何其他程序一样,我们需要保存我们的进度。这里的区别在于,我们不仅仅有一个包含所有项目资源的巨大文件,而是每个资源都有几个文件。
让我们通过保存场景来开始保存我们的进度,这相当直接。我们可以简单地转到文件 | 保存或按Ctrl + S(在 Mac 上为Command + S)。我们第一次保存场景时,会弹出一个窗口询问我们想要将文件保存到何处,你可以在我们项目的Assets文件夹内部保存它,但永远不要在文件夹外部保存;否则,Unity 将无法将其作为项目中的资源找到。这将在项目窗口中生成一个新的资源:场景文件。在下面的屏幕截图中,你可以看到我如何保存场景,将其命名为test,现在它显示在项目面板中:

图 2.37:场景文件
我们可以在保存对话框中创建一个文件夹来保存我们的场景,或者,如果你已经保存了场景,你可以使用项目窗口中的加号 (+) 图标创建一个文件夹,然后点击文件夹选项。最后,将创建的场景拖到该文件夹中。现在,如果你通过文件 | 新建场景菜单选项创建另一个场景,只需双击项目窗口中的场景资源即可回到上一个场景。试试看!
这只保存了场景,但 Prefab 和其他类型资源的任何更改都不会与该选项一起保存。相反,如果你想保存除了场景之外的所有资源更改,你可以使用文件 | 保存项目选项。这可能有点令人困惑,但如果你想保存所有更改,你需要同时保存场景和项目,因为只保存项目不会保存场景的更改。有时,确保一切已保存的最佳方式就是关闭 Unity,当你尝试在不同计算机或文件夹之间移动项目时,这是推荐的。这将显示一个提示,要求保存场景的更改,并将自动保存对其他资源(如 Prefab)所做的任何更改。
摘要
在本章中,我们快速介绍了 Unity 的基本概念。我们回顾了基本 Unity 窗口以及我们如何使用它们来编辑一个完整的场景,从导航它,然后创建预制对象(Prefab),到使用 GameObject 和组件操纵它们以创建我们自己的对象类型。我们还讨论了如何使用层次结构窗口将 GameObject 设置为父级以创建复杂对象层次结构,以及创建 Prefab 以重复利用和操作大量相同类型的对象。最后,我们讨论了如何保存我们的进度。
在下一章中,我们将学习不同的工具,如地形系统和 ProBuilder,来创建我们游戏级别的第一个原型。这个原型将作为我们场景将走向的预览,在全面生产之前测试一些想法。
第三章:使用地形和 ProBuilder 进行灰盒设计
现在我们已经掌握了使用 Unity 所需的所有必要概念,让我们开始设计我们的第一个关卡。本章的目的是学习如何使用地形工具来创建游戏的地形,然后使用 ProBuilder 以比使用立方体更详细的方式创建基础 3D 网格。在本章结束时,你将能够创建任何类型的场景原型,并在实际使用最终图形实现之前尝试你的想法。
具体来说,在本章中我们将探讨以下概念:
-
定义我们的游戏概念
-
使用地形创建景观
-
使用 ProBuilder 创建形状
让我们先从讨论我们的游戏概念开始,这将帮助我们草拟第一个关卡环境。
定义我们的游戏概念
在将第一个立方体添加到场景之前,有一个想法是我们将要创建的内容是很好的,因为我们需要有一个基本的游戏概念来开始设计第一个关卡。在本书中,我们将创建一个射击游戏,玩家将对抗试图摧毁玩家基地的敌人波次。
这个基地将位于一个(并非那么)秘密的位置,周围环绕着山脉:

图 3.1:我们的成品游戏
我们将在本书的进展过程中定义我们游戏的游戏机制,但有了这个基本的游戏高级概念,我们可以开始思考如何创建一个山丘景观和一个占位符玩家基地来开始。
在此基础上,在本章的下一节中,我们将学习如何使用 Unity 的地形工具来创建场景的景观。
使用地形创建景观
到目前为止,我们使用立方体来生成我们的关卡原型,但我们还了解到,这些形状有时无法代表我们可能需要的所有可能的对象。想象一下不规则的东西,比如一个完整的带有山丘、峡谷和河流的地形。使用立方体创建这样的不规则形状将是一场噩梦。另一个选择是使用 3D 建模软件,但问题是生成的模型会非常大且非常详细,以至于即使在高端 PC 上表现也不会很好。在这种情况下,我们需要学习如何使用 Unity 的地形系统,我们将在本章的第一节中这样做。
在本节中,我们将介绍与地形相关的以下概念:
-
讨论高度图
-
创建和配置高度图
-
创建和配置高度图
-
添加高度图细节
让我们先从讨论高度图开始,其纹理帮助我们定义地形的起伏。
讨论高度图
如果我们使用常规 3D 建模工具创建一个包含山丘、峡谷、陨石坑、山谷和河流的大型游戏区域,我们将遇到一个问题,那就是我们将为所有可能距离的对象使用完全详细的模型,从而浪费了渲染我们看不到的细节的资源。我们将从很远的距离看到地形的很多部分,所以这是一个严重的问题。
Unity 地形工具使用一种称为高度图的技术,以高效和动态的方式生成地形。它不是为整个地形生成大型 3D 模型,而是使用一个称为高度图的图像,它看起来像是从上往下拍摄的地形黑白照片。
在以下图像中,你可以看到苏格兰一个区域的黑白俯视图,白色代表较高,黑色代表较低:

图 3.2:苏格兰的高度图
在前面的图像中,你可以通过寻找图像中最白的区域来轻松地找到山脉的顶峰。海平面以下的一切都变成黑色,而中间的一切都使用灰度的渐变,代表最小和最大高度之间的不同高度。想法是图像中的每个像素都决定了该特定地形区域的高度。
Unity 地形工具可以从该图像自动生成一个 3D 网格,节省我们存储该地形完整 3D 模型所需的硬盘空间。此外,Unity 将随着我们的移动创建地形,为附近的区域生成高细节模型,为远处的区域生成低细节模型,使其成为一个高效的解决方案。
在以下图像中,你可以看到为地形生成的网格。你可以欣赏到地形较近的部分比较远的部分有更多的多边形:

图 3.3:生成的高度图网格
请注意,这项技术有其缺点,例如 Unity 在游戏过程中生成那些 3D 模型所需的时间,以及无法创建洞穴,但就目前而言,这对我们来说不是问题。
既然我们已经知道了高度图是什么,让我们看看如何使用 Unity 地形工具来创建我们自己的高度图。
创建和配置高度图
如果你点击GameObject | 3D Object | Terrain,你将看到一个巨大的平面出现在你的场景中,并在你的层次窗口中出现一个Terrain对象。那就是我们的地形,它很平坦,因为其高度图一开始是全黑的,所以其初始状态没有任何高度。
在以下图像中,你可以看到全新的Terrain对象的外观:

图 3.4:尚未绘制高度的地形
在开始编辑此地形之前,你必须配置不同的设置,例如地形高度图的尺寸和分辨率,这取决于你打算如何使用它。这不同于生成整个世界。我们的游戏将包括玩家的基地,他们将进行防御,因此地形将是小的。在这种情况下,一个 200 x 200 米大小的区域,周围环绕着山脉,将足够使用。
为了根据这些要求配置我们的地形,我们需要执行以下操作:
-
从层次或场景窗口中选择地形。
-
查看地形组件的检查器并展开它,如果它已经折叠的话。
-
点击山和齿轮图标(最右侧的选项)以切换到配置模式。在以下屏幕截图中,你可以看到该按钮的位置:

图 3.5:地形设置按钮
-
查找网格分辨率(在地形数据中)部分。
-
在两个设置中将地形宽度和地形长度更改为
200。这将表示我们的地形大小将是 200 x 200 米。 -
地形高度决定了可能的最大高度。我们 Height Map 的白色区域将是这个大小。我们可以将其减少到
500,以限制我们山脉的最大峰值:

图 3.6:地形分辨率设置
-
查找纹理分辨率(在地形数据中)部分。
-
将高度图分辨率更改为257 x 257:

图 3.7:高度图分辨率设置
高度图分辨率是包含地形不同部分高度的 Height Map 图像的大小。在我们的 200 x 200 米地形中使用 257 x 257 的分辨率意味着地形中的每平方米将被 Height Map 的略多于 1 个像素覆盖。每平方米的分辨率越高,你可以在该区域大小中绘制越多的细节。通常,地形特征很大,所以每平方米超过 1 个像素通常是资源浪费。找到你可以使用的最小分辨率,以便创建所需的细节。
你还希望设置的一个初始设置是初始地形高度。默认情况下,这是 0,因此你可以从底部开始绘制高度,但这种方式你无法在地形中挖洞,因为地形已经处于最低点。设置一点初始高度可以让你在需要时绘制河流路径和坑洞。
为了做到这一点,请执行以下操作:
-
在层次面板中选择我们的地形。
-
点击绘制地形按钮(第二个按钮)。
-
如果下拉菜单不是设置为设置高度,请将其设置为设置高度。
-
将高度属性设置为
50。这将表示我们希望所有地形从50米的高度开始,允许我们挖出最大深度为50米的洞:

图 3.8:设置高度地形工具位置
- 点击全部展平按钮。你会看到所有地形都已经提升到我们指定的
50米。这让我们剩下 450 米可以上升,基于我们之前指定的最大 500 米。
现在我们已经正确配置了高度图,让我们开始编辑它。
创建高度图
记住,高度图只是高度的一个图像,因此为了编辑它,我们需要在该图像中绘制高度。幸运的是,Unity 有工具允许我们在编辑器中直接编辑地形,并直接看到修改后高度的结果。为了做到这一点,我们必须遵循以下步骤:
-
在层次结构面板中选择我们的地形。
-
点击绘制地形按钮(第二个按钮,与上一节相同)。
-
将下拉菜单设置为提升或降低地形:

图 3.9:提升或降低地形工具位置
-
在笔刷选择器中选择第二个笔刷。这个笔刷有模糊的边缘,允许我们创建更柔和的高度。
-
将笔刷大小设置为30,这样我们就可以创建跨越 30 米区域的地面高度。如果你想创建更细腻的细节,可以减少这个数值。
-
将不透明度设置为10以减少每秒或每次点击绘制的地面高度:

图 3.10:平滑边缘笔刷
- 现在,如果你在场景视图中移动鼠标,你会看到如果你点击该区域,将会绘制的高度的小预览。可能你需要靠近地形来查看细节:

图 3.11:提升地形的预览区域
你可以看到的勾选图案允许你看到你正在编辑的物体的实际大小。每个单元格代表一个平方米。记住,有一个参考来查看你正在编辑的物体的实际大小,有助于防止你创建过大或过小的地形特征。也许你可以放入其他类型的参考,比如一个具有准确尺寸的大立方体,代表一个建筑,以获得你正在创建的山脉或湖泊的大小概念。记住,立方体默认大小为 1 x 1 x 1 米,所以缩放为 10,10,10 将给你一个 10 x 10 x 10 米的立方体。
-
按住,左键点击,并将光标拖动到地面上以开始绘制地形高度。记住,你可以按Ctrl + Z (Command+ Z 在 Mac 上)撤销任何不希望的改变。
-
尝试在我们的区域边界周围绘制山脉,这将代表我们基地的背景山丘:

图 3.12:绘制在地形边缘的山脉
我们现在在未来的基地周围有了不错的起始山丘。我们还可以在未来的基地周围画一条护城河。要做到这一点,请按照以下步骤操作:
- 在地形中间放置一个
50,10,50比例的立方体。这将作为我们要创建的基地的占位符:

图 3.13:基地区域的占位符立方体
-
再次选择地形和刷子按钮。
-
将刷子大小减少到
10。 -
按住Shift键,左键点击并拖动鼠标在地面上来绘制围绕我们的基础占位符的盆地。这样做会降低地形而不是升高它:

图 3.14:我们占位符基地周围的护城河
现在,我们有一个简单但很好的起始地形,它给我们一个基本的概念,了解我们的基地及其周围将看起来如何。在继续之前,我们将应用一些更精细的细节,使我们的地形看起来更好一些。在下一节中,我们将讨论如何使用不同的工具模拟地形侵蚀。
添加高度图细节
在前一节中,我们创建了一个地形的大致轮廓。如果你想让它看起来更逼真一些,那么你需要开始在这里和那里画很多小细节。通常,这会在关卡设计过程的后期进行,但既然我们现在正在探索地形工具,让我们现在看看。现在,我们的山看起来非常平滑。在现实生活中,它们通常更尖锐,所以让我们改进一下:
-
选择地形并点击刷子按钮,如前几节所述。
-
如果下拉菜单尚未设置,请将其设置为提升或降低地形。
-
选择第五个刷子,如图 3.15 所示。这个刷子形状不规则,因此我们可以在这里和那里画一些噪声。
-
将刷子大小设置为
50,这样我们就可以覆盖更大的区域:

图 3.15:用于随机性的云图案刷子
- 按住Shift键,在不拖动鼠标的情况下,在地面的小山上进行小点击。记得放大到你正在应用更精细细节的区域,因为它们在远处是看不见的:

图 3.16:使用上述刷子生成的侵蚀
这给我们的山增加了一些不规则性。现在,让我们想象我们想在山上有一个平坦的区域来放置装饰性的天文台或天线。按照以下步骤操作:
-
从下拉菜单中选择地形、刷子工具和设置高度。
-
将高度设置为
60。 -
选择全圆刷(第一个)。
-
在小山上画一个区域。你会看到,如果地形低于 60 米,它将上升;如果高于 60 米,它将下降:

图 3.17:平整的小山
- 你可以看到边界有一些需要平滑的粗糙角落:

图 3.18:未平滑的地形边缘
-
将下拉菜单更改为平滑高度。
-
选择第二个刷子,如图 3.19 所示,大小为
5,不透明度为10:

图 3.19:选中的平滑高度刷子
- 点击并拖动我们平坦区域的边缘,使它们更平滑:

图 3.20:平滑的地形边缘
我们可以继续添加细节,但我们可以先这样。下一步是创建玩家的基础,但首先,让我们探索 ProBuilder 以生成我们的几何形状。
使用 ProBuilder 创建形状
到目前为止,我们已经使用立方体和原始形状创建了简单的场景,这对您将要创建的大多数原型来说已经足够了,但有时,游戏中可能会有一些难以用常规立方体建模的复杂区域,或者您可能想在游戏中的某些部分添加一些更深的细节,以了解玩家将如何体验该区域。
在这种情况下,我们可以使用任何 3D 建模工具来完成这项工作,例如 3D Studio Max、Maya 或 Blender,但它们可能难以学习,而且在这个开发阶段您可能不需要它们的所有功能。幸运的是,Unity 有一个简单的 3D 模型创建工具叫做 ProBuilder,让我们来探索它。
在本节中,我们将介绍与 ProBuilder 相关的以下概念:
-
安装 ProBuilder
-
创建形状
-
网格操作
-
添加细节
ProBuilder 默认不包含在我们的 Unity 项目中,所以让我们先学习如何安装它。
安装 ProBuilder
Unity 是一个功能强大的引擎,但如果我们不使用所有这些工具,将它们添加到我们的项目中可能会使引擎运行得更慢,因此我们需要手动指定我们正在使用的 Unity 工具。为此,我们将使用 包管理器,这是一个我们可以用来选择将要需要的 Unity 包的工具。如您所回忆的,我们之前谈到了 Packages 文件夹。这基本上就是包管理器正在修改的内容。
为了使用此工具在我们的项目中安装 ProBuilder,我们需要执行以下操作:
- 点击 窗口 | 包管理器 选项:

图 3.21:包管理器选项
- 在刚刚打开的窗口中,确保 包 模式处于 Unity 注册表 模式,通过点击窗口左上角的 包 按钮并选择 Unity 注册表。与只显示项目已拥有的包的 项目内 选项不同,Unity 注册表 将显示您可以安装的所有官方 Unity 包:

图 3.22:显示所有包
-
等待左侧的包列表填充。确保您已连接到互联网以下载和安装包。
-
在该列表中查看 ProBuilder 包,并选择它。您也可以使用 包管理器 窗口右上角的搜索框:

图 3.23:包列表中的 ProBuilder
我使用的是 ProBuilder 版本 5.0.6,这是撰写本书时的最新版本。虽然您可以使用更新的版本,但使用它的过程可能会有所不同。您可以通过标题左侧的箭头查看旧版本。
- 点击 包管理器 右下角的 安装 按钮:

图 3.24:安装按钮
-
等待包安装;这可能需要一段时间。当导入弹出窗口完成后,安装按钮被移除标签替换时,您就可以知道过程已经结束。如果由于某种原因 Unity 冻结或超过 10 分钟,您可以随时重新启动它。
-
在 Windows 上转到编辑 | 首选项(在 Mac 上为Unity | 首选项)。
-
从左侧列表中选择ProBuilder选项。
-
将顶点大小设置为
2和线大小设置为1。这将在编辑其不同部分时帮助我们更好地可视化即将创建的 3D 模型:

图 3.25:配置 ProBuilder
顶点大小和线大小值很大(分别为2米和1米),因为我们不会编辑模型的细节,而是编辑像墙壁这样的大特征。考虑您可能想要根据您正在编辑的内容稍后对其进行修改。
虽然这已经是我们安装 ProBuilder 所需了解的关于包管理器的所有内容,但如果您想了解更多,可以在此处查看其文档:docs.unity3d.com/Manual/upm-ui.html。现在我们已经在我们的项目中安装了 ProBuilder,让我们来使用它吧!
创建形状
我们将通过创建一个用于地板的平面来开始玩家的基础。我们将通过以下步骤来完成此操作:
-
删除我们放置的作为基础占位符的立方体。您可以通过在层次结构中右键单击立方体然后按删除键来完成此操作。
-
打开 ProBuilder 并转到工具 | ProBuilder | ProBuilder 窗口:

图 3.26:ProBuilder 窗口选项
- 在打开的窗口中,点击新建形状按钮:

图 3.27:新建形状选项
-
在场景视图右下角出现的创建形状面板中,选择平面图标(第二行的第一个图标)。
-
展开形状属性和平面设置。
-
将宽度切割和高度切割设置为
2。我们稍后会需要这些细分。 -
点击并拖动地形以绘制平面。在您这样做的时候,检查创建形状面板中的大小值如何变化,并尝试使其具有大约
50的x和z值。 -
释放鼠标按钮,查看生成的平面:

图 3.28:创建的新形状
- 在层次结构中选择新创建的平面对象,并使用变换工具向上拖动一点。
我们需要将平面向上移动,因为它是在与地形完全相同的高度创建的。这导致了称为Z 冲突的效果,其中位于相同位置的像素在争夺确定哪个将被渲染以及哪个不会被渲染。
现在我们已经创建了地板,让我们学习如何通过操纵其顶点来改变其形状。
网格操纵
如果你选择了平面,你会看到它被细分为 3 x 3 的网格,因为我们设置了宽度和高度切割为2。我们这样做是因为我们将使用外部单元格来创建墙体,从而将其抬起。想法是修改这些单元格的大小,以在创建墙体之前勾勒出墙体的长度和宽度。为了做到这一点,我们将执行以下操作:
-
在层次结构中选择平面。
-
如果 ProBuilder 尚未打开,请打开它,并转到 工具 | ProBuilder | ProBuilder 窗口 选项。
-
从场景视图中出现的四个新按钮中选择第二个按钮(顶点):

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

图 3.30:启用选择隐藏
- 点击并拖动鼠标创建一个选择框,选择第二行顶点上的四个顶点:

图 3.31:顶点选择
- 点击 Unity 编辑器按钮组左上角的第二个按钮以启用移动工具,这将允许我们移动顶点。像变换工具一样,这可以用来移动任何对象,但为了移动顶点,这是我们的唯一选项。记住,一旦选择了顶点,就要这样做。你也可以按W键来启用移动工具。

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

图 3.33:移动顶点
- 对每一行的顶点重复 步骤 3 到 5,直到得到大小相似的墙面轮廓:

图 3.34:移动顶点以减少边缘单元格宽度
现在我们已经为墙体创建了轮廓,让我们添加新的面到网格中,以创建它们。为了使用我们创建的细分,或面,来制作墙体;我们必须选择并拉伸它们。按照以下步骤进行操作:
-
选择平面。
-
在场景视图中选择ProBuilder按钮的第四个按钮:

图 3.35:选择面工具
- 按住 Ctrl (Mac 上的 Command),点击墙面轮廓的每个面:

图 3.36:选择边缘面
- 在ProBuilder窗口中,寻找Extrude Faces按钮右侧的加号 (+) 图标。它位于窗口的红色部分:

图 3.37:拉伸面选项
-
在点击加号按钮后出现的窗口中,将距离设置为
5。 -
在该窗口中点击Extrude Faces按钮:

图 3.38:拉伸距离选项
- 现在,你应该看到墙体的轮廓已经从地面升起:

图 3.39:拉伸网格边缘
现在,如果你注意基础地板和墙壁与地形的接触,会发现有一点间隙。我们可以尝试将基础向下移动,但地板可能会消失,因为它会被地形埋藏。这里我们可以使用的一个小技巧是向下推墙壁,而不移动地板,这样墙壁就会埋入地形,而我们的地板会保持一定的距离。你可以在以下图片中看到它将如何看起来:

图 3.40:预期结果的切片
为了做到这一点,我们需要执行以下操作:
- 在场景视图中选择第三个ProBuilder按钮以启用边缘选择:

图 3.41:选择边缘工具
-
在按住Ctrl(Mac 上的Command)的同时,选择所有墙壁的底部边缘。
-
如果你选择了不想要的边缘,只需在按住Ctrl(Mac 上的Command)的同时再次点击它们以取消选择,同时保持当前选择:

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

图 3.43:启用线框模式
- 通过按场景面板左上角的第二个按钮(或键盘上的W键)启用移动工具:

图 3.44:对象移动工具
- 将边缘向下移动,直到它们完全埋入地形中:

图 3.45:重叠的面
现在我们有了基础网格,我们可以开始使用几个其他 ProBuilder 工具向其添加细节。
添加细节
让我们从给基础应用一点斜边开始,在角落处进行一点切割,使其不那么尖锐。为此,请按照以下步骤操作:
- 使用边缘选择工具(ProBuilder按钮中的第三个),选择我们模型的顶部边缘:

图 3.46:正在选择顶部墙壁边缘
-
在ProBuilder窗口中,按住斜边按钮右侧的加号(+)图标。
-
设置距离为
0.5:

图 3.47:要生成的斜边距离
- 点击斜边边缘。现在你可以看到我们墙壁的顶部部分有轻微的斜边:

图 3.48:斜边处理的结果
- 可选地,你也可以对内部墙壁的底部部分做同样的操作:

图 3.49:应用于地板-墙壁边缘的斜边
可以添加的另一个细节是在地面中间挖一个坑,作为我们需要避免掉入的陷阱,并让敌人通过 AI 避免它。为了做到这一点,请按照以下步骤操作:
-
通过点击第四个 ProBuilder 场景视图按钮启用面选择模式。
-
选择地板。
-
在 ProBuilder 窗口中点击 细分面 选项。您将得到一个分成四部分的地面。
-
再次点击该按钮,以获得 4 x 4 的网格:

图 3.50:细分地板
-
按住 Ctrl (*Mac 上的 Command) 使用 选择面 工具(场景视图顶部部分的 ProBuilder 按钮中的第三个)选择四个内部地板瓷砖。
-
通过点击场景视图左上角的第四个按钮或按键盘上的 R 键启用 缩放 工具。与 移动 工具一样,这可以用于缩放任何对象,而不仅仅是顶点:

图 3.51:缩放工具
- 使用位于 Gizmo 中心灰色立方体,缩小中心瓷砖:

图 3.52:内部单元格缩小
-
在 ProBuilder 窗口中点击 拉伸面 按钮。
-
使用 移动工具 将拉伸的面向下推。
-
右键点击 ProBuilder 窗口标签并选择 关闭标签。我们需要回到地形编辑,并且打开 ProBuilder 不会让我们舒适地进行操作:

图 3.53:关闭标签选项
- 选择地形并将其降低,以便我们可以看到坑洞:

图 3.54:降低地形以便可见坑洞
摘要
在本章中,我们学习了如何使用高度图和 Unity 地形工具,如 绘制高度 和 设置高度 来创建山丘和河流,从而创建大型地形网格。我们还看到了如何使用 ProBuilder 创建自己的 3D 网格,以及如何操纵模型的顶点、边和面来创建游戏的原型基础模型。我们没有讨论可以应用于我们的网格或高级 3D 建模概念的任何性能优化,因为这需要整个章节,并且超出了本书的范围。目前,我们的主要重点是原型设计,所以我们对我们当前级别的状态感到满意。
在下一章中,我们将学习如何通过集成我们使用外部工具创建的资产(文件)来下载和替换这些原型模型,以最终艺术作品。这是提高我们游戏图形质量的第一步,我们将在第三部分,改进图形 的结尾完成。
加入 Discord 与我们同行!
与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。
扫描二维码或访问链接以加入社区。

packt.link/handsonunity22
第四章:导入和整合资源
在上一章中,我们创建了我们的关卡原型。现在,让我们假设我们已经编写了游戏并进行了测试,确认游戏想法很有趣。有了这个,现在是时候用真正的完成艺术来替换原型艺术了。我们将在下一章第五章,C#和视觉脚本简介中实际编写游戏,但现在为了学习目的,让我们先跳过这部分。为了使用最终资源,我们需要学习如何获取它们(图像、3D 模型等),如何将它们导入 Unity,以及如何将它们整合到我们的场景中。
在本章中,我们将探讨以下主题:
-
导入资源
-
整合资源
-
配置资源
让我们先学习如何在 Unity 中获取资源,例如 3D 模型和纹理。
导入资源
我们可以使用的项目资源来源有很多。我们可以简单地从我们的艺术家那里接收文件,从不同的免费和付费资源网站上下载它们,或者我们可以使用Asset Store,Unity 的官方虚拟资源商店,在那里我们可以获取免费和付费的资源,这些资源可以与 Unity 一起使用。我们将使用从互联网和 Asset Store 下载资源的混合方式,以便使用所有可能资源。
在本节中,我们将介绍与导入资源相关的以下概念:
-
从互联网导入资源
-
从 Asset Store 导入资源
-
从 Unity 包导入资源
-
让我们先探索第一个资源来源,互联网。
从互联网导入资源
在获取项目中的艺术资源方面,让我们从我们的地形纹理开始。请记住,我们的地形已经被涂上了网格图案,所以想法是用草地、泥土、岩石和其他类型的纹理来替换它。为了做到这一点,我们需要图像。在这种情况下,这类图像通常是不同地形图案的俯视图,并且它们有“可平铺”的要求,这意味着您可以在它们的连接处重复它们而不会出现明显的图案。您可以在以下图像中看到这个例子:

图 4.1:左 - 草地块;右 - 将同一草地块分开以突出纹理平铺
左侧的草地看起来像是一张巨大的单一图像,但如果您仔细观察,应该能够看到一些重复出现的图案。在这种情况下,这片草地实际上只是单个图像在网格中重复了四次,就像右侧的图像一样。这样,您可以通过重复单个小图像来覆盖大面积,从而在用户的计算机上节省大量 RAM。
策略是获取这类图像来绘制我们的地形。您可以从几个地方获取它们,但最简单的方法是使用Google Images或任何图像搜索引擎。在使用这些来源的内容之前,请始终检查版权许可。为此,请按照以下步骤操作:
-
打开您的浏览器(Chrome、Safari、Edge 等)。
-
前往您偏好的搜索引擎。在这种情况下,我将使用 Google。
-
使用关键词
PATTERN tileable texture,将PATTERN替换为你正在寻找的地形类型,例如grass tileable texture或mud tileable texture。在这种情况下,我将输入grass tileable texture,然后按Enter键进行搜索。 -
切换到图片搜索模式:

图 4.2:Google 搜索图片
- 选择任何你认为适合所需草地的纹理,然后点击它。请记住,纹理必须是草地的俯视图,并且必须重复。
在选择之前尝试检查图片的分辨率。目前尝试选择分辨率小于 1024 x 1024 的方形图片。
- 右键单击打开的图片,并选择另存为…:

图 4.3 另存为…选项
- 将图片保存到任何你记得的文件夹中。
现在你已经下载了图片,你可以通过几种方式将其添加到你的项目中。最简单的一种方法如下:
-
使用文件资源管理器(Mac 上的Finder)定位你的图片。
-
在 Unity 的项目窗口中定位或创建
Textures文件夹。 -
将文件资源管理器和Unity 项目窗口并排放置。
-
将文件从文件资源管理器拖动到Unity 项目窗口中的
Textures文件夹:

图 4.4:从 Windows 文件资源管理器拖动纹理到 Unity 的项目视图
对于这些简单的纹理,任何搜索引擎都可以有所帮助,但如果你想要用详细的墙壁和门替换玩家的基础几何形状,或者在你的场景中放置敌人,你需要获取 3D 模型。如果你在搜索引擎中使用诸如“免费僵尸 3D 模型”等关键词搜索,你会找到无数免费和付费的 3D 模型网站,如 TurboSquid 和 Mixamo,但那些网站可能会有问题,因为那些网格通常没有为在 Unity 中使用或甚至用于游戏而准备。你会发现具有非常高的多边形计数、不正确的尺寸或方向、未优化的纹理等问题。为了避免这些问题,我们希望使用更好的来源,在这种情况下,我们将使用 Unity 的 Asset Store,让我们来探索它。
从 Asset Store 导入资源
Asset Store 是 Unity 的官方资源市场,你可以在这里找到许多模型、纹理、声音,甚至完整的 Unity 插件来扩展引擎的功能。在这种情况下,我们将限制自己下载 3D 模型来替换玩家的基础原型。你将想要获取具有模块化设计的 3D 模型,这意味着你将得到几个部件,如墙壁、地板、角落等。你可以将它们连接起来创建任何类型的场景。
为了做到这一点,你必须遵循以下步骤:
- 在 Unity 中点击窗口 | 资产商店,这将打开一个新窗口,显示资产商店已迁移。在 Unity 的早期版本中,你可以在编辑器中直接看到资产商店,但现在,建议在常规网页浏览器中打开它,所以点击在线搜索按钮,这将在你首选的浏览器中打开网站
assetstore.unity.com/。此外,你可以勾选从菜单始终在浏览器中打开,以便在点击窗口 | 资产商店时直接打开页面:

图 4.5:资产商店迁移消息
- 在顶部菜单中,点击3D类别来浏览 3D 资产:

图 4.6:3D 资产菜单
- 在最近打开的页面中,点击右侧所有类别面板中3D类别的右侧箭头,然后打开环境并勾选科幻标记,因为我们将会制作一个未来主题的游戏:

图 4.7:3D 资产菜单
如你所见,有几个类别可以用来查找不同类型的资产,如果你想的话,可以挑选另一个。在环境类别中,你可以找到可用于为你的游戏生成场景的 3D 模型。
- 如果你需要,你可以为资产付费,但现在让我们先隐藏付费的资产。你可以通过点击左上角的按价格排序选项,并选择免费资产选项来实现:

图 4.8:免费资产选项
- 在搜索区域,找到任何看起来符合你审美需求的资产并点击它。记得要留意户外资产,因为大多数环境包通常只包含室内场景。在我的例子中,我选择了一个名为科幻风格模块包的资产,它适用于室内和室外。请注意,当你阅读这篇文档时,这个包可能已经不存在了,所以你可能需要选择另一个。如果你找不到合适的包,你可以从 GitHub 仓库下载并选择我们使用的资产文件:

图 4.9:资产商店搜索包预览
- 现在,你将在资产商店窗口中看到包的详细信息。在这里,你可以找到关于包的描述、视频/图片、包的内容,以及最重要的部分,即评论,你可以看到这个包是否值得获取:

图 4.10:资产商店包详细信息
- 如果你对这个包没有异议,点击添加到我的资产按钮,如果需要,登录 Unity,然后点击在 Unity 中打开按钮。你可能需要接受浏览器打开 Unity 的提示;如果是这样,只需接受即可:
图 4.11:切换应用
- 这将再次打开包管理器,但这次是在我的资产模式下,显示你从资产商店下载的所有资产的列表,你刚刚选择的资产在列表中突出显示:

图 4.12:资源管理器显示资源
-
在窗口的右下角点击下载,等待完成。然后点击导入。
-
一段时间后,包内容窗口将出现,允许你选择你想要在项目中使用的包中的确切资源。现在,保持原样并点击导入:

图 4.13:导入资源选择
- 经过一段时间导入后,你将在你的项目窗口中看到所有包文件。
请注意,导入大量完整包会增加你的项目大小,并且之后你可能想要删除未使用的资源。此外,如果你导入的资源产生了错误,阻止你播放场景,只需删除包中附带的所有.cs文件。它们通常在名为Scripts的文件夹中。这些是可能与你的 Unity 版本不兼容的代码文件:

图 4.14:在播放时触发的代码错误警告
在继续本章内容之前,尝试使用资源商店,按照之前的步骤下载一个角色 3D 模型。为了做到这一点,你必须完成与我们为关卡环境包所做相同的步骤,但在资源商店的3D | 角色 | 人形类别中查找。在我的情况下,我选择了机器人英雄:PBR HP Polyart包:

图 4.15:游戏中使用的角色包
现在,让我们探索 Unity 资源的另一个来源:Unity 包。
从 Unity 包导入资源
资源商店不是资产包的唯一来源;你可以从互联网上获取.unitypackage 文件,或者可能从想要与你分享资源的同事那里获取。
为了导入.unitypackage文件,你需要执行以下操作:
- 前往资产 | 导入包 | 自定义包选项:

图 4.16:导入自定义包
-
在显示的对话框中搜索
.unitypackage文件。 -
在出现的导入 Unity 包窗口中点击导入选项,与我们在资源商店部分看到的相同。
现在我们已经导入了很多艺术资源,让我们学习如何在场景中使用它们。
集成资源
我们刚刚导入了很多可以以多种方式使用的文件,所以本节的想法是看看 Unity 如何将这些资源与需要它们的 GameObject 和组件集成。
在本节中,我们将介绍与导入资源相关的以下概念:
-
集成地形纹理
-
网格集成
-
集成材质
让我们先使用可重复纹理来覆盖地形。
集成地形纹理
为了将纹理应用到我们的地形上,请按照以下步骤操作:
-
选择地形对象。
-
在检查器中,点击地形组件的刷子图标(第二个按钮)。
-
从下拉菜单中选择绘制纹理:

图 4.17:地形绘制纹理选项
-
点击编辑地形层… | 创建层选项。
-
在出现的纹理选择器窗口中找到并双击之前下载的地形纹理:

图 4.18:纹理绘制选择器
-
你将看到纹理将立即应用到整个地形上。
-
重复步骤 4和步骤 5以添加其他纹理。这次,你会发现该纹理不会立即应用。
-
在地形层部分,选择你创建的新纹理以开始使用该纹理进行绘制。在我的例子中,我使用了泥地纹理。
-
当你编辑地形时,在画笔部分,你可以选择并配置一个画笔来绘制地形。
-
在场景视图中,绘制你想应用该纹理的区域。
-
如果你的纹理图案过于明显,打开新层 N部分,该部分位于画笔部分之上,其中N是一个取决于你创建的层的数字。
每次你将纹理添加到地形上,你都会看到在项目视图中创建了一个名为新层 N的新资产。它包含你创建的地形层的数据,如果你需要,可以使用该资产在其他地形上。你还可以重命名该资产以赋予它一个有意义的名称。此外,你可以根据自己的需要将这些资产重新组织到自己的文件夹中。
- 使用左侧的三角形打开该部分,并在平铺设置部分中增加大小属性,直到你找到一个图案不那么明显的合适大小:

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

图 4.20:使用三种不同纹理绘制我们的地形的成果
当然,我们可以使用系统的大量高级工具来大幅改进这一点,但现在让我们保持简单。现在,让我们看看我们如何将 3D 模型集成到我们的游戏中。
集成网格
如果你选择我们之前下载的 3D 资产并点击其右侧的箭头,项目窗口中会出现一个或多个子资产。这意味着我们从资产商店(FBX 文件)下载的 3D 模型文件是定义 3D 模型的资产的容器:

图 4.21:网格选择器
其中一些子资产是网格,它们是一系列定义模型几何形状的三角形集合。你可以在文件中至少找到一个这样的网格子资产,但你也可以找到几个,这可能会发生在你的模型由很多部分组成的情况下。例如,一辆车可以是一个单一的刚性网格,但这不会让你旋转它的轮子或打开它的车门;它将只是一个静态的车,如果车只是场景中的一个道具,这可能就足够了,但如果玩家将能够控制它,你可能需要对其进行修改。想法是,你的汽车的所有部件都是不同的 GameObject,彼此之间以某种方式相互关联,这样如果你移动其中一个,所有这些都会移动,但你仍然可以独立旋转它的部件。
当你将 3D 模型文件拖动到场景中(不是子资产)时,Unity 将根据艺术家创建的方式自动为每个部分创建所有对象及其适当的父级。你可以在层次结构中选择对象并探索其所有子对象以查看这一点:

图 4.22:子对象选择
此外,你会发现每个对象可能都有自己的Mesh Filter和Mesh Renderer组件,每个组件仅渲染模型的那一部分。记住,Mesh Filter是一个具有渲染网格资产的引用的组件,因此Mesh Filter是使用我们之前讨论过的那些网格子资产的组件。在动画角色的案例中,你将找到Skinned Mesh Renderer组件,但我们将稍后在第三部分,改进图形中讨论该组件。
现在,当你将 3D 模型文件拖动到场景中时,你将得到一个类似于模型是一个 Prefab 并且你正在实例化的结果。但是 3D 模型文件比 Prefab 更有限,因为你不能对模型应用更改。如果你已经将对象拖动到场景中并编辑它以具有你想要的行为,我建议你创建一个 Prefab 以获得我们在第二章中讨论的所有好处,例如将更改应用于 Prefab 的所有实例等。永远不要从模型文件创建大量模型的实例——始终从基于该文件创建的 Prefab 创建它们,以便你可以向它添加额外的行为。
这就是 3D 网格的基本用法。现在,让我们探索纹理集成过程,这将使我们的 3D 模型更加详细。
集成纹理
可能你的模型已经应用了纹理,但整个模型都被应用了洋红色。如果是这种情况,这意味着资产没有准备好与你在创建项目时选择的通用渲染管道(URP)模板一起工作。
Asset Store 中的一些资产是由第三方编辑器创建的,可能旨在用于 Unity 的旧版本:

图 4.23:渲染带有错误材料或根本没有材料的网格
修复洋红色资产的一个选项是使用渲染管线转换器,这是一个工具,可以找到它们并将它们(如果可能)重新配置为与 URP 一起工作。为此,每次导入看起来像洋红色的资产时,都要执行以下步骤:
-
前往窗口 | 渲染 | 渲染管线转换器。
-
从下拉菜单中选择内置到 URP选项:

图 4.24:将旧资产升级到 URP
-
滚动直到你看到材料升级复选框并勾选它。
-
点击左下角的初始化转换器按钮。这将显示所有需要升级的材料列表。我们将在稍后讨论材料:

图 4.25:修复材料以与 URP 一起工作
- 点击转换资产按钮,查看模型是否已修复。
你需要关闭窗口以便它检测到之前打开时不存在的新洋红色资产。这种方法的不利之处在于,有时它不会正确升级材料。幸运的是,我们可以通过手动重新应用对象的纹理来修复这个问题。即使你的资产工作得很好,我也建议你无论如何都重新应用你的纹理,这样你就可以更多地了解材料的概念。
纹理不是直接应用到对象上的。这是因为纹理只是控制模型外观的所有配置中的一个。为了改变模型的外观,你必须创建一个材料。材料是一个独立的资产,其中包含大量关于 Unity 如何渲染你的对象的设置。你可以将该资产应用到具有相同图形设置的多个对象上,如果你更改材料的设置,它将影响所有使用它的对象。它就像一个图形配置文件。
为了创建一个应用你对象纹理的材料,你需要遵循以下步骤:
-
在项目窗口中,点击窗口左上角的加号(+)按钮。
-
在该菜单中点击材料选项。
-
为你的材料命名。这通常是我们要应用材料的资产名称(例如,
Car,Ship,Character等等)。 -
将创建的材料拖动到场景中的模型实例上。如果你用拖动的资产在对象上移动鼠标,你将能够看到该材料将如何预览,如果是新材料,它将是白色的。我们将在以下步骤中更改这一点。
-
释放鼠标以应用材料。
-
如果你的对象有多个部分,你需要将材料拖动到每个部分。
拖动材料将改变你拖动的对象的MeshRenderer组件的材料属性。
-
选择材料并点击基础贴图属性左侧的圆圈(见图 4.23)。
-
在纹理选择器中,点击您模型的纹理。仅通过观察可能很难找到纹理。通常,纹理的名称将与模型名称匹配。如果不匹配,您需要尝试不同的纹理,直到找到适合您物体的纹理。此外,您可能会发现几个与您的模型名称相同的纹理。只需选择看起来颜色合适的纹理,而不是那些看起来是黑白或浅蓝色的纹理;我们稍后会使用那些:

图 4.26:URP 材质的基础地图属性
通过这种方式,您已经成功通过材质将纹理应用到物体上。对于使用相同纹理的每个对象,只需拖动相同的材质。现在我们已经对如何应用模型纹理有了基本的了解,让我们学习如何在将模型散布到场景之前正确配置导入设置。
配置资产
正如我们之前提到的,艺术家习惯于在 Unity 之外创建艺术资产,这可能会导致该工具中看到的资产与 Unity 导入的资产之间的差异。例如,3D Studio Max 可以在厘米、英寸等中工作,而 Unity 则使用米。我们刚刚下载并使用了大量资产,但跳过了配置步骤来解决这些差异,所以现在让我们来看看。
在本节中,我们将介绍与导入资产相关的以下概念:
-
配置网格
-
配置纹理
让我们先讨论如何配置 3D 网格。
配置网格
为了更改模型的导入设置,您需要找到您下载的模型文件。包含 3D 模型的文件扩展名有多种,最常见的是.fbx文件,但您可能会遇到其他文件,如.obj、.3ds、.blender、.mb等。您可以通过扩展名来识别文件是否为 3D 网格:

图 4.27:所选资产路径扩展
此外,您可以点击资产,在检查器中查看您可以在以下屏幕截图中看到的选项卡:

图 4.28:网格材质设置
现在您已经找到了 3D 网格文件,您可以正确地配置它们。目前,我们唯一需要考虑的是模型的正确比例。艺术家习惯于使用不同软件和不同设置进行工作;也许一位艺术家使用米作为其度量单位创建了模型,而其他艺术家则使用英寸、英尺等。当导入以不同单位创建的资产时,它们可能会不成比例,这意味着我们会得到人类比建筑物更大的结果等。
最佳解决方案是直接要求艺术家进行修复。如果所有资产都是在您的公司创作的,或者如果您使用了外部资产,您可以要求艺术家按照您公司的标准进行修复,但就目前而言,您可能是一位自学 Unity 的单个开发者。幸运的是,Unity 有一个设置,允许您在使用 Unity 之前重新缩放原始资产。为了更改对象的“缩放因子”,您必须执行以下操作:
-
在您的项目窗口中定位 3D 网格。
-
将其拖动到场景中。您会看到场景中会出现一个对象。
-
使用GameObject | 3D Object | Capsule选项创建一个胶囊。
-
将胶囊放在您拖入编辑器的模型旁边。看看缩放是否合理。想法是胶囊代表一个人类(2 米高),这样您就有了一个缩放的参考:

图 4.29:使用胶囊作为缩放参考
-
如果模型比预期的大或小,请在项目窗口中再次选择网格(不是您拖到编辑器中的 GameObject 实例)并您将在检查器中看到一些导入设置。在图片中,我们可以认为模型具有良好的相对尺寸,但为了学习目的,请执行以下步骤。
-
查找缩放因子属性并修改它,如果您的模型比预期小,则增加它;如果相反,则减少它:

图 4.30:模型网格选项
-
点击检查器底部的应用按钮。
-
重复步骤 6和步骤 7,直到您得到期望的结果。
有许多其他选项可以配置,但现在我们先到此为止。现在,让我们讨论如何正确配置我们模型的纹理。
配置纹理
再次,这里有许多设置可以配置,但现在我们只关注纹理大小。想法是使用最适合该纹理使用的尺寸,这取决于许多因素。
首先要考虑的因素是对象将被看到的距离。如果您正在创建第一人称游戏,您可能会看到很多足够近的对象,这可以证明大纹理的合理性,但也许您有很多远处的对象,比如建筑物顶部的广告牌,您永远不会足够接近去看到细节,所以您可以使用较小的纹理。
另一个要考虑的因素是对象的重要性。如果您正在创建赛车游戏,您可能会在屏幕上有许多 3D 模型,它们将在屏幕上显示几秒钟,玩家永远不会关注它们;他们将会关注道路和其他车辆。在这种情况下,例如街道上的垃圾桶可以有一个小的纹理和低多边形模型,用户永远不会注意到这一点(除非他们停下来欣赏风景,但这是可以接受的)。
最后,你可以有一个俯视视角的游戏,它永远不会放大场景,所以在这个地方,第一人称游戏中具有大纹理的对象将具有更少的细节纹理。在以下图像中,你可以看到较小的飞船可以使用较小的纹理:

图 4.31:从不同距离看到的相同模型
纹理的理想大小是相对的。通常找到它的方法是改变其大小,直到你找到在游戏中最接近的位置可以看到对象时,最小的可能尺寸且质量尚可。这是一个试错法。为了做到这一点,你可以执行以下操作:
-
定位 3D 模型并将其放入场景中。
-
将场景视图相机放置在一个可以显示对象在游戏中可能最大尺寸的位置。例如,在一个第一人称射击(FPS)游戏中,相机可以几乎紧挨着对象,而在一个俯视游戏中,它会在对象上方几米处。同样,这取决于你的游戏。记住,我们的游戏是第三人称射击游戏。
-
在与包一起导入的文件夹中或从之前创建的材料中找到并选择对象使用的纹理。它们通常具有
.png、.jpg或.tif扩展名。 -
在检查器中,查看最大尺寸属性并减小它,尝试下一个更小的值。例如,如果纹理是2048,尝试1024。
-
点击应用并检查场景视图,看看质量是否大幅下降,或者变化是否不明显。你会感到惊讶的。
-
重复步骤 4到5,直到你得到一个质量差的成果。在这种情况下,只需提高之前的分辨率以获得可接受的质量。当然,如果你针对 PC 游戏,你可以期望比移动游戏更高的分辨率。
现在你已经导入、集成和配置了你的对象,让我们用这些资产创建玩家的基础。
组装场景
让我们开始用我们下载的环境包替换我们的原型基础。为此,你必须执行以下操作:
- 在我们之前导入的环境包中,找到包含场景不同部分所有模型的文件夹,并尝试找到一个角落。你可以在项目窗口中的搜索栏中搜索
corner关键字:

图 4.32:网格选择器
-
在我具体的情况下,角落的外侧和内侧是分开的模型,所以我需要将它们放在一起。
-
将其放置在原型基础的任何角落相同的位置:

图 4.33:将网格定位在占位符上进行替换
-
找到与那个角落相连以创建墙壁的正确模型。再次,你可以在项目窗口中尝试搜索
wall关键字。 -
实例化它并定位,使其与角落相连。如果它不能完美地适应,不要担心;你将在必要时稍后检查场景。
你可以选择一个对象并按V键来选择所选对象的顶点。然后你可以拖动它,点击变换 Gizmo 中间的矩形,并将其直接指向另一个对象的顶点。这被称为顶点吸附。它允许你精确地连接场景中的两个部件。

图 4.34:连接两个模块
- 重复放置墙壁,直到达到玩家基地的另一端并放置另一个角落。你可能得到的墙壁比原始原型略大或略小,但这没关系:

图 4.35:连接的模块链
在按住Ctrl键(Mac 上的Command键)的同时移动对象,可以固定对象的位置,以便墙的克隆可以轻松地定位在旁边。另一个选项是在检查器中手动设置Transform组件的Position属性。
-
完成剩余的墙壁,并销毁我们在 ProBuilder 中制作的原始立方体。请记住,这个过程很慢,你需要有耐心。
-
通过寻找地板砖并在整个表面上重复它们来添加地板:

图 4.36:带有坑洞的地板模块
-
使用包中的其他模块化部件添加你想要的任何细节。
-
将所有这些部件放入一个名为
Base的容器对象中。请记住创建一个空对象并将基础部件拖入其中:

图 4.37:网格子资产
经过大量练习后,你将逐渐积累关于模块化场景设计常见陷阱和良好实践的丰富经验。所有包都有不同的模块化设计理念,因此你需要适应它们。
摘要
在本章中,我们学习了如何导入模型和纹理并将它们集成到场景中。我们讨论了如何将纹理应用到地形上,如何用模块化模型替换我们的原型网格,如何将这些纹理应用到它们上,以及如何根据对象的用途适当配置资产,同时考虑到几个标准。
通过这些,我们已经完成了这本书的第一部分,并讨论了我们在整本书中会使用的几个基本 Unity 概念。在第二部分中,我们将开始编写游戏的游戏玩法,比如玩家的移动和健康系统。我们将开始学习如何创建自己的组件来为我们的对象添加行为以及脚本的基本结构。
第五章:C# 和可视化脚本简介
Unity 拥有很多优秀的内置工具来解决游戏开发中最常见的问题,比如我们之前看到的问题。即使是同一类型的两个游戏也有它们自己的一些细微差别,使游戏变得独特,Unity 无法预见这一点,这就是为什么我们需要脚本。通过编码,我们可以以几种方式扩展 Unity 的功能,以实现我们需要的精确行为,所有这些都可以通过一个众所周知的语言——C# 来完成。但除了 C#,Unity 还有 可视化脚本,这是一种通过节点图工具生成代码的方法。这意味着你可以通过拖动 节点(代表可以链式连接的操作的盒子)来创建脚本,而不需要编写代码:

图 5.1:可视化脚本图的示例
尽管本质上两种方法都可以达到相同的结果,但我们可以根据不同的用途使用它们。通常,由于通常很大且对性能非常敏感,游戏的核心逻辑是用 C# 编写的。但有时使用可视化脚本而不是代码可以让非程序员团队成员,如艺术家或游戏设计师,在游戏中有更多自由来编辑小的更改,尤其是在平衡或视觉效果方面。
另一个例子是游戏设计师通过可视化脚本原型设计想法,后来程序员将在想法获得批准后将这些想法转换为 C# 脚本。此外,C# 程序员可以为可视化脚本程序员创建节点。
这些工具的混合方式在团队之间差异很大,所以尽管在下一章我们将主要关注 C#,我们还将查看我们将要创建的脚本的可视化脚本等效版本。这样,你将有机会在方便的时候根据团队结构选择使用其中一个。
在本章中,我们将探讨以下脚本概念:
-
创建脚本
-
使用事件和指令
我们将创建自己的 Unity 组件,学习脚本的基本结构以及我们可以如何执行操作和公开属性以进行配置,无论是使用 C# 还是可视化脚本。我们在这里不会创建任何实际的游戏代码,只是提供一些示例脚本,以便在下一章开始这样做。让我们先讨论脚本创建的基本知识。
创建脚本
创建行为的第一步是创建脚本资产;这些文件将包含我们组件行为背后的逻辑。C# 和可视化脚本都有自己的资产类型来实现这一点,所以让我们来探讨如何在两个工具中实现这一点。
在这本书中需要一些编程知识。然而,在本节中,我们将讨论基本的脚本结构,以确保你在下一章编写游戏行为时有一个坚实的基础。即使你对 C# 熟悉,也尽量不要跳过这一节,因为我们将涵盖 Unity 特定的代码结构。
在本节中,我们将探讨以下脚本创建概念:
-
初始设置
-
创建 C#脚本
-
添加字段
-
创建 Visual Scripting 图
我们将创建我们的第一个脚本,这个脚本将用于创建我们的组件,讨论创建所需工具,并探讨如何将我们的类字段暴露给编辑器。让我们从脚本创建的基本知识开始。
初始设置
通过在包管理器中安装Visual Scripting包,我们可以添加对 Visual Scripting 的支持,就像我们在前几章中添加其他包一样,但由于 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 选项,您可以执行以下操作:
-
打开Unity Hub。
-
前往安装部分。
-
点击您正在使用的 Unity 版本右上角的轮形按钮,然后点击添加模块:

图 5.2:向 Unity 安装添加模块
-
检查表示Visual Studio的选项;该选项的描述将根据您使用的 Unity 版本和平台而有所不同。
-
点击右下角的继续按钮:

图 5.3:安装 Visual Studio
- 确认您接受条款和条件,然后点击安装:

图 5.4:接受条款和条件
- 等待操作结束。这可能需要几分钟。可能会有一些与平台和版本相关的 Visual Studio 步骤;如果是这样,只需按照它们进行即可。
如果你有一个首选的 IDE,你可以自己安装它并配置 Unity 使用它。如果你负担得起或者你是教师或学生(在这些情况下它是免费的),我推荐 Rider。这是一个功能强大的 IDE,拥有许多你将喜欢的 C# 和 Unity 功能;然而,它对于本书并不是必需的。为了设置 Unity 使用自定义 IDE,请按照以下步骤操作:
-
打开项目。
-
在编辑器顶部菜单中转到编辑 | 首选项(在 Mac 上为Unity | 首选项)。
-
从左侧面板选择外部工具菜单。
-
从外部脚本编辑器中选择你首选的 IDE;Unity 将自动检测支持的 IDE:

图 5.5:选择自定义 IDE
- 如果你没有在列表中找到你的 IDE,你可以使用浏览…选项。请注意,通常需要使用此选项的 IDE 并没有得到很好的支持——但值得一试。
最后,一些 IDE,如 Visual Studio、Visual Studio Code 和 Rider,都有 Unity 集成工具,你需要在项目中安装这些工具,这是可选的但可能很有用。通常,Unity 会自动安装这些工具,但如果你想确保它们已安装,请按照以下步骤操作:
-
打开包管理器(窗口 | 包管理器)。
-
将包下拉菜单设置为Unity 注册表模式:

图 5.6:启用 Unity 注册表模式
- 在列表中搜索你的 IDE 或使用搜索栏进行过滤。在我的情况下,我使用了 Rider,我可以找到一个名为 JetBrains Rider Editor 的包:

图 5.7:自定义 IDE 编辑器扩展安装——在这个例子中是 Rider 的
- 通过查看包管理器右下角的按钮来检查你的 IDE 集成包是否已安装。如果你看到一个安装或更新按钮,点击它,但如果它显示为已安装,则一切设置就绪。
现在我们已经配置了一个 IDE,让我们创建我们的第一个脚本。
创建 C# 脚本
C# 是一种面向对象的语言,在 Unity 中也是如此。任何想要扩展 Unity 的时候,我们都需要创建自己的类——一个包含我们想要添加到 Unity 中的指令的脚本。如果我们想要创建自定义组件,我们需要创建一个继承自 MonoBehaviour 的类,这是每个自定义组件的基类。
我们可以直接在 Unity 项目中使用编辑器创建 C# 脚本文件,并将它们排列在其他 assets 文件夹旁边的文件夹中。创建脚本的最简单方法是按照以下步骤操作:
-
选择你想要添加我们即将创建的组件的任何 GameObject。由于我们只是在测试,所以选择任何对象。
-
在检查器底部点击添加组件按钮,并在点击添加组件后列表底部查找新脚本选项:

图 5.8:新脚本选项
- 在 名称 字段中输入所需的脚本名称,然后点击 创建并添加。在我的情况下,我将命名为
MyFirstScript,但为您游戏中的脚本尝试输入描述性的名称,无论长度如何:

图 5.9:命名脚本
建议您使用帕斯卡大小写(Pascal case)来命名脚本。在帕斯卡大小写中,用于玩家射击功能的脚本会被命名为 PlayerShoot。每个单词的首字母都大写,且不能使用空格。
- 你可以在 项目视图 中检查如何创建与你的脚本同名的新的资产。请记住,每个组件都有自己的资产,我建议你将每个组件放在一个
Scripts文件夹中:

图 5.10:脚本资产
- 现在,你也会看到你的 GameObject 在检查器窗口中有一个新的组件,其名称与你的脚本相同。所以,你现在已经创建了你第一个
component类:

图 5.11:脚本添加到 GameObject 中
现在我们已经创建了一个 component 类,请记住,类本身不是组件。它是对组件应该是什么的描述——组件应该如何工作的蓝图。要实际使用组件,我们需要通过基于类创建组件来实例化它。每次我们使用编辑器将组件添加到对象时,Unity 都会为我们实例化它。通常,我们不会使用 newC# 关键字来实例化组件,而是通过使用编辑器或专用函数。
现在,你可以像添加任何其他组件一样,使用检查器窗口中的 添加组件 按钮将你的新空组件添加到其他对象中。然后你可以在 脚本 类别中查找该组件或通过名称搜索它:

图 5.12:在脚本类别中添加自定义组件
在这里需要考虑的是,我们可以将相同的组件添加到多个 GameObject 中。我们不需要为每个使用该组件的 GameObject 创建一个类。我知道这是基本的程序员知识,但请记住,我们在这里试图回顾基础知识。
现在我们有了我们的组件,让我们通过以下步骤来探索它的外观并进行类结构回顾:
-
在 项目视图 中定位脚本资产,并双击它。请记住,它应该在你之前创建的
Scripts文件夹中。 -
等待 IDE 打开;这可能需要一段时间。当你看到你的脚本代码及其关键字被正确着色时,你就知道 IDE 已经完成了初始化,这会根据所需的 IDE 而有所不同。在 Rider 中,它看起来就像 图 5.13 中所示的那样。在我的情况下,我知道 Rider 已经完成了初始化,因为
MonoBehaviour类型与脚本名称的颜色相同:

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

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

图 5.15:MyFirstScript 类定义继承自 MonoBehaviour
现在我们已经有了我们的 C# 脚本,让我们添加字段来配置它。
添加字段
在前面的章节中,当我们添加 Rigidbody 或不同类型的碰撞体作为组件时,仅仅添加组件是不够的。我们需要正确配置它们以实现我们需要的精确行为。例如,Rigidbody 有 Mass 属性来控制物体的重量,而碰撞体有 Size 属性来控制它们的形状。这样,我们可以为不同的场景重用相同的组件,防止类似组件的重复。使用 Box 碰撞体,我们可以通过改变大小属性来表示一个立方体或矩形盒子。我们的组件也不例外;如果我们有一个移动对象的组件,并且我们想让两个对象以不同的速度移动,我们可以使用具有不同配置的相同组件。
每个配置都是一个 字段 或 变量,我们可以在这里存储参数的值。我们可以以两种方式在编辑器中创建可编辑的类字段:
-
通过将字段标记为
public,但打破了封装原则 -
通过创建私有字段并使用属性公开它
现在,我们将介绍这两种方法,但如果你对 面向对象编程(OOP)概念不熟悉,例如封装,我建议你使用第一种方法。
假设我们正在创建一个移动脚本。我们将使用第一种方法——即通过添加 public 字段——添加一个表示速度的可编辑数字字段。我们将按照以下步骤进行:
-
通过双击脚本打开它,就像我们之前做的那样。
-
在类括号内,但不在它们内部的任何括号内,添加以下代码:

图 5.16:在我们的组件中创建速度字段
public 关键字指定变量可以在类的作用域之外被看到和编辑。代码中的 float 部分表示变量使用的是十进制数字类型,而 speed 是我们为我们的字段选择的名称——这可以是任何你想要的内容。你可以使用其他值类型来表示其他类型的数据,例如 bool 用于复选框或 Booleans 和 string 用于文本。
- 要应用更改,只需在 IDE 中保存文件(通常通过按 Ctrl + S 或 Command + S)并返回 Unity。当你这样做时,你会在编辑器的右下角注意到一个小型加载轮,这表明 Unity 正在编译代码。你必须在轮子停止转动之前不能测试更改:

图 5.17:加载轮
记住,Unity 会编译代码;不要在 IDE 中编译。
- 编译完成后,你可以在检查器窗口中看到你的组件,并且应该有一个 Speed 变量,允许你设置你想要的速度。当然,现在,这些变量没有任何作用。Unity 不会通过变量的名称来识别你的意图;我们需要以某种方式设置它以便使用,但我们将稍后进行操作:

图 5.18:一个公共字段,用于编辑组件稍后将要使用的数据
如果你没有看到速度变量,请检查本章末尾的 常见初学者 C# 脚本错误 部分,它将提供有关如何调试编译错误的提示。
-
尝试将相同的组件添加到其他对象中,并设置不同的速度。这将向你展示不同 GameObject 中的组件是如何独立的,允许你通过不同的设置更改它们的一些行为。
-
定义属性的第二种方法类似,但不是创建一个
public字段,而是创建一个private字段,鼓励封装,并通过SerializeField属性将其公开,如图中所示。

图 5.19:在检查器窗口中公开私有属性
如果你不太熟悉面向对象编程中的封装概念,只需使用第一种方法,这对于初学者来说更加灵活。如果你创建一个 private 字段,它将不会被其他脚本访问,因为 SerializeField 属性只将变量暴露给编辑器。记住,Unity 不允许你使用构造函数,所以设置初始数据和注入依赖的唯一方法是通过序列化的 private 字段或 public 字段,并在编辑器中设置它们(或者使用依赖注入框架,但这超出了本书的范围)。为了简单起见,我们将在本书的大部分练习中使用第一种方法。
如果您想,尝试创建其他类型的变量并检查它们在检查器中的外观。尝试将float替换为bool或string,如之前建议的那样。请注意,并非所有可能的 C#类型都由 Unity 识别;通过这本书,我们将学习最常用的支持类型。现在我们知道了如何通过数据配置我们的组件,让我们使用这些数据来创建一些行为。
现在我们有了我们的 C#脚本,让我们看看如何在视觉脚本中做同样的事情。
创建视觉脚本
由于我们需要为 C#脚本创建脚本资产,因此需要创建视觉脚本编写的等效项,即脚本图形,并将其附加到我们的 GameObject 上,尽管这次采用不同的方法。在继续之前,请注意,我们的对象必须只有 C#或视觉脚本版本,但不能两者兼有,否则行为将被应用两次,每次一个版本。
实际上,只需为要尝试的版本执行步骤,或者如果您想进行实验,则在不同的对象中执行这两个步骤。
让我们创建一个执行以下操作的视觉脚本:
-
创建一个新的 GameObject,我们将向其中添加视觉脚本。
-
向其中添加脚本机器组件。此组件将执行我们即将创建的视觉脚本图形:

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

图 5.21:使用新按钮创建视觉脚本图形资产
-
如果出现警告,请点击立即更改选项。这将防止在脚本上的更改在游戏运行时影响游戏,因为警告所说,它可能导致代码不稳定。始终停止游戏,更改代码,然后再次播放。
-
点击编辑图形按钮以打开视觉脚本编辑器窗口。您可以将脚本图形标签拖动到编辑器的任何部分以合并该窗口:

图 5.22:视觉脚本资产编辑器
- 将鼠标放在视觉脚本编辑器网格的空白区域,同时按住中间鼠标按钮,移动鼠标以滚动通过图形。在 MacBooks 和 Apple Magic Mouses 上,您可以使用两个手指在触控板上进行滚动。
我们所做的是创建包含我们脚本代码的视觉图形资产,并通过脚本机器组件将其附加到 GameObject 上。与 C#脚本不同,我们无法直接附加图形资产;这就是为什么我们需要脚本机器为我们运行组件。
关于字段,我们在 C#脚本中创建的字段包含在脚本本身中,但对于Visual Graph来说,它们的工作方式略有不同。当我们添加了Script Machine组件时,还添加了另一个组件:Variables组件。这将保存所有Visual Script Graph的变量,一个 GameObject 可以包含。这意味着我们添加到对象的所有图表都将共享这些变量。如果你想创建特定于图表的变量,也可以,但它们不会在 Inspector 中暴露,这种方式也简化了从其他对象的脚本中访问变量的过程。也要记住,你可能需要向对象添加多个图表,因为每个图表将负责不同的行为,这样我们就可以根据需要混合和匹配它们。
为了将一个变量添加到我们的 GameObject 中,以便我们的图表可以使用它,让我们做以下操作:
-
选择一个添加了Visual Script的 GameObject(带有Script Machine组件)并查看Variables组件。
-
点击显示(New Variable Name)的输入字段,并输入变量的名称。在我的例子中,这是
speed。如果你看不到这个选项,点击Variables组件名称左侧的三角形。 -
点击Variables组件的Plus (+)按钮。
-
在Type下拉菜单中,选择Float。
-
可选地,你可以在Value字段中设置一个初始值:

图 5.23:为 Visual Graph 创建变量
我们创建了一个speed变量,我们可以在 GameObject 中配置它来改变所有附加到我们的 GameObject 的Visual Scripts Graphs的工作方式,或者至少是使用那个Variable值的那些。考虑一下,你可能会有不同种类的速度,比如移动速度和旋转速度,所以在实际情况下,你可能希望变量名更加具体一些。
在 Visual Scripting 中使用的Variables组件也被称为黑板,这是一种常见的编程技术。这个黑板是我们对象几个值的容器,就像内存或数据库一样,我们的对象的其他几个组件将查询并使用这些值。C#脚本通常在其内部包含自己的变量。随着我们的脚本创建并准备配置,让我们看看如何让它们都做些事情。
使用事件和指令
现在我们有了脚本,我们准备用它做些事情。在本章中,我们不会实现任何有用的功能,但我们将确定基础概念,以便在下一章中添加我们将要创建的脚本的有趣行为。
在本节中,我们将介绍以下概念:
-
C#中的事件和指令
-
Visual Scripting 中的事件和指令
-
在指令中使用字段
-
常见初学者 C#脚本错误
我们将探索Unity 事件系统,这将允许我们通过执行指令来响应不同的情况。这些指令也将受到编辑器值的影响。最后,我们将讨论常见的脚本错误及其解决方法。让我们从介绍 Unity 事件在 C#中的概念开始。
C#中的事件和指令
Unity 允许我们以因果关系的方式创建行为,这通常被称为事件系统。事件是 Unity 正在监控的情况——例如,当两个对象碰撞或被销毁时,Unity 会告诉我们这种情况,使我们能够根据我们的需求做出反应。例如,当玩家与子弹碰撞时,我们可以减少玩家的生命值。在这里,我们将探讨如何监听这些事件,并通过一些简单的操作来测试它们。
如果你习惯了事件系统,你会知道它们通常要求我们订阅某种监听器或代理,但在 Unity 中,有一个更简单的方法可用。对于 C#脚本,我们只需要编写一个与我们要使用的事件名称完全相同的函数——我的意思是完全相同。如果名称中的任何一个字母的格式不正确,它将不会执行,并且不会发出警告。这是最常见的初学者错误,所以请注意。对于视觉脚本,我们将添加一种特殊的节点,但稍后再讨论 C#版本。
在 Unity 中有很多事件或消息需要监听,所以让我们从最常见的一个开始——Update。这个事件将告诉你 Unity 何时想要更新你的对象,这取决于你行为的目的;有些不需要。Update逻辑通常是需要不断执行的事情——更准确地说,是在每一帧执行。记住,每个游戏都像是一部电影——一系列快速切换到屏幕上的图像,看起来我们有了连续的运动。在Update事件中执行的一个常见操作是稍微移动对象,通过这样做,每一帧都会使你的对象持续移动。
我们将在稍后了解我们可以使用Update和其他事件或消息做什么。现在,让我们专注于如何使我们的组件至少能够监听这个事件。实际上,基本脚本已经包含两个可以立即使用的函数,一个是Update,另一个是Start。如果你不熟悉 C#中的方法概念,我们指的是以下截图中的代码片段,它已经包含在我们的脚本中。试着在你的脚本中找到它:

图 5.24:一个名为 Update 的函数,它将在每一帧执行
你会注意到(通常是)在void Update()行上方的绿色文本行(取决于 IDE)——这被称为注释。这些基本上被编译器忽略。它们只是你可以为自己留下的笔记,并且必须始终以//开头,以防止 Unity 尝试执行它们并失败。我们将使用这个来暂时禁用代码行。
现在,为了测试这个功能是否真的起作用,让我们添加一个始终要执行的指令。没有比print更好的测试函数了。这是一条简单的指令,告诉 Unity 将消息打印到控制台,开发者可以在那里看到各种消息,以检查一切是否正常工作。用户永远不会看到这些消息。它们类似于开发者有时在游戏中出现问题时要求你提供的经典日志文件。
为了使用函数测试 C#中的事件,请按照以下步骤操作:
-
通过双击脚本文件来打开它。
-
为了测试,在事件函数中添加
print("test");。在下面的屏幕截图中,你可以看到一个如何在Update事件中执行此操作的示例。请记住,必须精确地写入指令,包括正确的大小写、空格和引号符号:

图 5.25:在所有帧中打印消息
- 保存文件,转到 Unity,并玩游戏。
记得在从 IDE 切换回 Unity 之前保存文件。这是 Unity 知道你的文件已更改的唯一方式。一些 IDE,如 Rider,会自动为你保存文件,但我不建议你在大型项目中使用自动保存(你不想意外地重新编译未完成的工作——在包含大量脚本的项目中,这会花费很长时间)。
-
查找控制台标签并选择它。这通常位于项目视图标签旁边。如果你找不到它,请转到窗口 | 通用 | 控制台,或按Ctrl + Shift + C(在 macOS 上为Command + Shift + C)。
-
你将在控制台标签页上看到每帧都打印出新的消息“
test”。如果你看不到这个,记得在玩游戏之前保存脚本文件。 -
你可能会看到一个单独的消息,但它的右侧数字在增加;这意味着相同的消息出现了多次。尝试点击控制台的折叠按钮来改变这种行为。
-
让我们也测试一下
Start函数。向其中添加print("test Start");,保存文件,并玩游戏。完整的脚本应如下所示:

图 5.26:测试 Start 和 Update 函数的脚本
如果你现在检查控制台并滚动到最上方,你会看到一个单独的"test Start"消息和随后的许多"test"消息。正如你所猜到的,Start事件告诉你 GameObject 已被创建,并允许你在其生命周期的开始执行一次需要发生的代码。
对于 void Update() 语法,我们将告诉 Unity,此行以下括号内的任何内容都是一个将在所有帧中执行的功能。将 print 指令放在 Update 括号内(类括号内的括号)内非常重要。此外,print 函数期望在其括号内接收一个要打印的值,称为参数或参数。在我们的例子中,我们想要打印简单的文本,在 C# 中它必须用引号括起来。最后,函数中如 Update 或 Start 的所有指令必须以分号结束。
在这里,我挑战您尝试添加另一个名为 OnDestroy 的事件,使用 print 来发现它何时执行。一个小建议是播放和停止游戏,查看控制台底部以测试此功能。
对于高级用户,如果您的 IDE 允许,您也可以使用断点。断点允许在执行特定代码行之前完全冻结 Unity,以查看我们的字段数据随时间的变化并检测错误。在这里,我将向您展示在 Rider 中使用断点的步骤,但 Visual Studio 版本应该类似:
-
如果尚未安装,请安装属于您 IDE 的 Unity 包。检查 包管理器 中的 JetBrains Rider 编辑器 包。在 Visual Studio 的情况下,安装 Visual Studio 编辑器 包。
-
在您想要添加断点的行的左侧垂直栏上单击:

图 5.27:打印指令中的断点
- 前往 运行 | 连接到 Unity 进程。如果您正在使用 Visual Studio,请前往 调试 | 连接 Unity 调试器:

图 5.28:用 Unity 进程攻击我们的 IDE
-
从列表中查找您想要测试的特定 Unity 实例。如果有的话,列表将显示其他打开的编辑器或正在执行的调试构建。
-
如果这不起作用,请检查编辑器是否处于调试模式,查看编辑器右下角的错误图标。如果错误图标看起来是蓝色的带有复选框,那么它是正常的,但如果它看起来是灰色的并且被划掉,请单击它并单击 切换到调试模式:

图 5.29:从发布模式切换到调试模式
停止调试过程不会关闭 Unity。它只会将 IDE 从编辑器中分离出来。
现在,让我们探索使用事件和指令的视觉脚本等效方法。
视觉脚本中的事件和指令
在视觉脚本中,事件和指令的概念保持不变,但当然这将通过图中的节点来完成。记住,一个节点代表图中的一个指令,我们可以将它们连接起来以链式连接每个指令的效果。为了在我们的图中添加事件和打印指令,请执行以下操作:
-
打开 视觉脚本图(双击视觉脚本资产)。
-
右键单击默认创建的 On Start 和 On Update 节点,然后单击 Delete。即使这些事件是我们需要的,我也想让你看到如何从头创建它们:

图 5.30:删除节点
-
在 Graph 的任何空白区域右键单击,并在 Search 框中输入
start。第一次可能需要一段时间。 -
在左侧带有绿色复选框的列表中选择 On Start 元素。在这种情况下,我知道这是一个事件,因为我了解它,但通常你会因为它们没有输入引脚(在下一步中会有更多关于这个的说明)而将其识别为事件:

图 5.31:搜索 On Start 事件节点
-
将事件节点右侧的白色箭头(也称为输出流程引脚)拖动并释放鼠标按钮到任何空白区域。
-
在 Search 框中搜索
print节点,选择显示为 Mono Behaviour:Print 的节点。这意味着当 On Start 事件发生时,连接的节点将被执行,在这种情况下是 print。这就是我们开始将指令链接到事件的方式:

图 5.32:创建一个连接到事件的打印节点
-
将位于 Print 节点 Message 输入引脚左侧的空圆圈拖动到任何空白区域并释放。这个引脚有一个圆圈表示它是一个参数引脚,当执行引脚时将使用的数据。带有绿色箭头的流程引脚代表节点将被执行的顺序。
-
选择 String Literal 选项,这将创建一个节点,允许我们指定要打印的消息:

图 5.33:创建一个字符串字面量节点
- 在空白的白色框中写入要打印的消息:

图 5.34:指定要打印的消息
- 玩游戏并查看控制台打印的消息。确保场景中只有视觉脚本版本,以避免将控制台中的消息与 C# 版本混淆。您还可以在视觉脚本中使用不同的消息文本,以确保哪些是真正执行的。
您可以通过将 Print 节点右侧的引脚(流程输出引脚)拖动到 On Start 上来将更多操作链接到 On Start,并链接新的节点,但我们将在稍后进行。现在,我们的脚本正在做些事情,让我们让指令使用我们创建的字段,以便脚本使用它们的配置。
在指令中使用字段
我们已经创建了字段来配置组件的行为,但到目前为止我们还没有使用它们。我们将在下一章创建有意义的组件,但我们会经常需要使用我们创建的字段来改变对象的行为。到目前为止,我们还没有真正使用我们创建的 speed 字段。然而,按照测试代码是否工作(也称为调试)的想法,我们可以学习如何使用函数中的字段数据来测试值是否为预期的,根据字段值改变控制台 print 的输出。
在我们当前的 C# 脚本中,我们的 speed 值在运行时不会改变。然而,作为一个例子,如果你正在创建一个带有护盾伤害吸收的生命系统,并且想要测试减少的伤害计算是否正常工作,你可能想要将计算值打印到控制台并检查它们是否正确。
这里的想法是将 print 函数内部的固定消息替换为字段。当您这样做时,print 将在控制台中显示字段的值。所以,如果您在 speed 中设置一个值为 5 并打印它,您将在控制台中看到很多显示 5 的消息,print 函数的输出由字段控制。为了测试这一点,您在 Update 函数中的 print 消息应该如下所示:

图 5.35:使用字段作为 print 函数的参数
如您所见,我们只是将字段的名称放在引号之外。如果您使用引号,您将打印一个 "``speed" 消息。在其他场景中,您可以在某些移动函数中使用这个 speed 值来控制移动速度,或者您可能创建一个名为 "fireRate"(字段使用 驼峰命名法 而不是 Pascal 命名法,首字母小写)的字段来控制一枪和下一枪之间的冷却时间:

图 5.36:打印当前速度
现在,为了使 Visual Script 图形打印出我们在 Variables 组件中创建的 speed 变量的值,让我们做以下操作。
-
打开 Visual Scripting 图形资产(双击它)。
-
在左侧的面板中,选择 Object 选项卡以显示对象拥有的所有变量——基本上是我们之前在 Variables 组件中定义的变量。
-
使用变量框左侧的两条线将
speed变量拖动到图形的任何空白区域。这将在图形中创建一个 GetVariable 节点来表示变量。请注意,目前拖动可能存在一个错误,所以您可能需要尝试几次,尝试从左侧部分拖动:

图 5.37:将变量拖动到节点中以供使用
- 将获取变量节点右侧的空圆圈拖动到打印节点消息输入引脚左侧的圆圈上。这将替换之前连接到字符串文本节点的连接。此节点没有输入或输出流节点(绿色箭头节点),因为它们是仅提供数据给其他节点的数据节点。在这种情况下,当打印需要执行时,它将执行获取变量以获取要读取的文本:

图 5.38:将速度变量连接到打印节点
-
右键单击字符串文本节点并删除它。
-
播放游戏并观察。
所有这些,我们现在有了开始创建实际组件所需的工具。在继续之前,让我们回顾一下你可能会遇到的一些常见错误,如果你这是第一次在 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:编写错误指令时的错误提示
现在,如果你使用错误的大小写编写事件,情况会更糟。你可以为其他目的创建名为Start和Update的函数,无论你想要什么名字。将update或start写成小写是完全有效的,因为 C#会认为你打算将这些函数用作常规函数,而不是事件。因此,不会显示错误,你的代码将无法正常工作。尝试将update写成Update,看看会发生什么:

图 5.41:Update 函数中的错误大小写将编译函数但不会执行
另一个错误是将指令放在函数括号之外,例如在类的括号内或括号外。这样做将不会给函数任何提示,说明何时需要执行。因此,在Event函数之外的print函数是没有意义的,它将显示如下图 5.42和图 5.43中的错误之一。
这次,错误信息并不非常详细。C#期望你创建一个函数或字段——可以直接放在类中的结构:

图 5.42:放置错误的指令或函数调用
最后,另一个经典的错误是忘记关闭打开的括号。如果你不关闭一个括号,C#将不知道一个函数在哪里结束,另一个函数在哪里开始,或者类函数在哪里结束。这可能听起来有些多余,但 C#需要这一点才能定义得完美。在下面的屏幕截图中,你可以看到这会是什么样子:

图 5.43:缺少闭合括号
这个错误有点难以捕捉,因为代码中的错误显示在实际错误之后。这是由于 C#允许你将函数放在函数内部(不常用)的事实,因此 C#会在稍后检测到错误,要求你添加一个闭合括号。然而,由于我们不希望在Start中放置Update,我们需要在Start的末尾之前修复错误。控制台中的错误信息将是描述性的,但同样,除非你 100%确定位置是正确的,否则不要按照消息建议的位置放置闭合括号。
你可能会遇到很多错误,除了这些之外,但它们都是一样的。IDE 会为你提供提示,控制台会显示消息;随着时间的推移,你会学会它们。只需保持耐心,因为每个程序员都会经历这个过程。还有其他类型的错误,例如运行时错误,代码可以编译但在执行时由于某些配置错误而失败,或者最糟糕的是:逻辑错误,你的代码可以编译并执行而没有错误,但并没有做你想要的事情。
摘要
在本章中,我们探讨了你在创建脚本时将使用的的基本概念。我们讨论了脚本资产的概念以及 C#脚本必须继承自MonoBehaviour才能被 Unity 接受以创建我们自己的脚本。我们还看到了如何混合事件和指令来为对象添加行为,以及如何在指令中使用字段来自定义它们的行为。所有这些操作都是使用 C#和视觉脚本完成的。
我们刚刚探索了脚本的基础知识以确保每个人都处于同一水平线上。然而,从现在开始,我们将假设你具备一些编程语言的基本编码经验,并且知道如何使用诸如if、for、array等结构。如果不具备,你仍然可以阅读这本书,并在需要时通过 C#入门书籍来补充你不理解的部分。
在下一章中,我们将开始看到如何使用我们所学的内容来创建移动和生成脚本。
加入我们的 Discord 频道!
与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过 Ask Me Anything(问我任何问题)环节与作者聊天,等等。
扫描二维码或访问链接加入社区。

packt.link/handsonunity22
第六章:实现运动和生成
在上一章中,我们学习了脚本的基础知识,所以现在让我们为我们的游戏创建第一个脚本。我们将看到如何使用Transform组件通过脚本移动对象的基础知识,这将应用于我们的玩家通过键盘按键的运动、子弹的恒定运动以及其他对象的运动。此外,我们还将看到如何在游戏中创建和销毁对象,例如玩家和敌人射击的子弹以及敌人波生成器。这些操作可以在多个场景中使用,所以我们将探索几个以加强这个概念。
在本章中,我们将探讨以下脚本概念:
-
实现运动
-
实现生成
-
使用新的输入系统
我们将首先通过脚本组件来控制我们的角色通过键盘移动,然后我们将让我们的玩家射击子弹。需要考虑的是,我们将首先看到每个部分的 C#版本,然后展示视觉脚本等效版本。
实现运动
几乎游戏中的每个对象都以某种方式移动:玩家角色通过键盘;敌人通过 AI;简单的向前移动的子弹等等。在 Unity 中有几种移动对象的方法,所以我们将从最简单的一种开始,即通过Transform组件。
在本节中,我们将探讨以下运动概念:
-
通过
Transform移动对象 -
使用输入
-
理解 Delta Time
首先,我们将探讨如何在脚本中访问Transform组件以驱动玩家运动,以便稍后根据玩家的键盘输入应用运动。最后,我们将探索 Delta Time 的概念以确保在每台计算机上运动速度的一致性。我们将开始学习Transform API 以创建一个简单的运动脚本。
通过 Transform 移动对象
Transform是包含对象的平移、旋转和缩放的组件,因此每个运动系统,如物理或路径查找,都会影响此组件。有时,我们想要通过创建自己的脚本来根据我们的游戏以特定方式移动对象,该脚本将处理我们需要的运动计算并修改Transform以应用它们。
这里隐含的一个概念是组件会改变其他组件。在 Unity 中编码的主要方式是创建与其他组件交互的组件。在这里,想法是创建一个可以访问另一个并告诉它做某事:在这种情况下,移动。要创建一个告诉Transform移动的脚本,请执行以下操作:
- 创建并添加一个名为
PlayerMovement的脚本到我们的角色中,就像我们在上一章中所做的那样。在这种情况下,它将是之前下载的动画 3D 模型(从项目视图拖动 3D 资产到场景中)。记住在创建后,将脚本移动到脚本文件夹中:

图 6.1:在角色中创建玩家移动脚本
-
双击创建的脚本资产以打开 IDE 进行代码编辑。
-
我们正在移动,这种移动应用于每个帧。因此,这个脚本将只使用
Update函数或方法,我们可以移除Start(移除未使用的函数是一种良好的实践):

图 6.2:仅包含 Update 事件函数的组件
- 要使我们的对象沿着其局部前进轴(z 轴)移动,请将
transform.Translate(0,0,1);行添加到Update函数中,如图图 6.3所示:
每个组件都继承了一个Transform字段(具体来说,是一个获取器),它是对放置该组件的 GameObject 的Transform的引用;它代表我们组件的兄弟Transform。通过这个字段,我们可以访问Transform的Translate函数,该函数将接收应用于 x、y 和 z 局部坐标的偏移量。

图 6.3:一个简单的向前移动脚本
- 保存文件并玩游戏以查看移动。确保相机指向角色,以便正确查看脚本的效果。
您会注意到玩家移动得太快。这是因为我们正在使用固定的 1 米速度,并且因为Update在所有帧上执行,所以我们每帧移动 1 米。在一个标准的 30 FPS 游戏中,玩家每秒将移动 30 米,这太多了,但可能我们的计算机以远高于这个 FPS 的速度运行游戏。我们可以通过添加speed字段并使用编辑器中设置的值而不是固定的 1 米值来控制玩家的速度。您可以在图 6.4中看到一种实现方式,但请记住我们在第五章,C#和视觉脚本简介中讨论的其他选项:

图 6.4:创建速度场并将其用作移动脚本的 z 速度
现在,如果您保存脚本以应用更改并在编辑器中设置玩家的速度,您就可以玩游戏并查看结果。在我的情况下,我使用了0.1,但您可能需要另一个值(关于这一点,请参阅理解 Delta 时间部分):

图 6.5:设置每帧 0.1 米的速度
现在,对于视觉脚本版本,首先请记住不要混合我们的脚本的 C#和视觉脚本版本,不是因为不可能,而是因为我们现在想保持事情简单。因此,您可以从玩家对象中删除脚本并添加视觉脚本版本,或者您可以创建两个玩家对象并启用和禁用它们以尝试两种版本。我建议为脚本的 C#版本创建一个项目,然后创建第二个项目以实验视觉脚本版本。
此脚本的视觉脚本图将如下所示:

图 6.6:设置每帧 0.1 米的速度
如您所见,我们在PlayerGameObject 中添加了一个脚本机组件。然后,我们在脚本机组件中按下新建按钮,创建了一个名为PlayerMovement的新图。我们还创建了一个名为speed的浮点变量,其值为0.1。在图中,我们添加了更新事件节点并将其连接到Transform的平移(X,Y,Z)节点,这与 C#版本类似,将沿着对象的局部轴移动。最后,我们将平移的Z参数引脚连接到表示我们在 GameObject 中创建的速度的GetVariable节点。如果您将这个图与我们在 C#版本中使用的代码进行比较,它们基本上是相同的更新方法和Translate函数。如果您不记得如何创建这个图,您可以回到第五章,C#和视觉脚本简介,回顾这个过程。
您会注意到玩家会自动移动。现在让我们看看如何根据玩家输入(如键盘和鼠标)来执行移动。
使用输入
与 NPC 不同,我们希望玩家的移动由玩家的输入驱动,基于他们按下的键、鼠标移动等。为了知道是否按下了某个键,例如向上箭头,我们可以使用Input.GetKey(KeyCode.W)这一行,它将返回一个布尔值,指示KeyCode枚举中指定的键是否被按下,在这种情况下是W。我们可以将GetKey函数与If语句结合使用,以便在按键时执行翻译。
让我们按照以下步骤实现键盘移动:
- 使用下面的代码,使向前移动仅在按下W键时执行,如下一个截图所示:

图 6.7:在按下 W 键之前条件化移动
- 我们可以通过添加更多的
If语句来添加其他移动方向,例如向后移动使用A和D来移动左右,如下一个截图所示。注意我们如何使用负号在需要沿相反轴方向移动时反转速度:

图 6.8:检查 W、A、S 和 D 键的按下状态
- 如果您也想考虑箭头键,您可以在
if语句中使用OR,如下面的截图所示:

图 6.9:检查 W、A、S、D 和箭头键的按下状态
- 保存更改,并在播放模式下测试移动。
需要注意的是,首先,我们还有另一种方法通过配置输入管理器将多个键映射到单个动作——这是一个可以创建动作映射的地方。其次,在撰写本文时,Unity 发布了一个比这个更可扩展的新输入系统。
目前,我们将使用这个,因为它足够简单,可以使我们的 Unity 脚本入门更容易,但在具有复杂输入的游戏中,建议寻找更高级的工具。
现在,对于可视化脚本版本,图表将看起来像这样:

图 6.10:可视化脚本中的输入移动
如你所见,与 C#版本相比,图表的大小显著增加,这可以作为开发者为什么更喜欢编码而不是使用可视化工具的例子。当然,我们有几种方法可以将这个图表拆分成更小的块,使其更易于阅读,并且还需要考虑我需要将节点挤压到同一张图片中。此外,在图表中,我们只看到了用于前进和后退的示例图表,但你可以很容易地根据这个图表推断出横向移动所需的步骤。像往常一样,你还可以检查项目的 GitHub 仓库以查看完成的文件。
观察图表,你可以快速观察到它与 C#版本的相似之处;我们将If节点链接到更新事件节点,如果第一个If节点条件为真,它将在玩家的前方方向执行平移。如果该条件为假,我们将False输出节点链接到另一个检查其他按键压力的If节点,在这种情况下,我们使用乘法(标量)节点来反转速度。
你可以注意到像If这样的节点,它们有多个流程输出引脚来分支代码的执行。
你还可以注意到获取键(Key)节点的使用,这是之前使用的相同获取键函数的可视化脚本版本。当你在搜索框中查看这个节点时,你会看到该函数的所有版本,在这种情况下,我们选择了GetKey(Key)版本;接收名称(字符串)的那个版本工作方式不同,我们不会介绍那个版本:

图 6.11:所有版本的输入获取键
我们还使用了Or节点将两个获取键(Key)函数组合成一个条件,提供给If。这些条件运算符可以在搜索框的逻辑类别中找到:

图 6.12:布尔逻辑运算符
需要强调的一点是使用乘法节点将速度变量的值乘以-1。我们需要创建一个浮点字面值节点来表示-1 这个值。接下来,当然所有程序员都会注意到我们如何使用If节点的True和False输出引脚存在一些限制,但我们会稍后解决这个问题。最后,考虑到这种实现方式在第一个输入成功读取时可能会阻塞第二个输入读取;当我们在本节稍后添加旋转时,我们将讨论一种修复这个问题的方法。
现在,让我们实现鼠标控制。在本节中,我们只涵盖鼠标移动引起的旋转;在下一节中我们将实现发射子弹:实现生成。在鼠标移动的情况下,我们可以得到一个值,表示鼠标在水平和垂直方向上移动了多少。这个值不是一个布尔值,而是一个数字:通常被称为轴的输入类型。轴的值将指示移动的强度,而该数字的符号将指示方向。例如,如果 Unity 的"Mouse X"轴显示0.5,这意味着鼠标以适中的速度向右移动,但如果显示-1,则快速向左移动,如果没有移动,则显示0。游戏手柄的摇杆也是如此;名为水平的轴表示常见摇杆中左摇杆的水平移动,因此如果玩家将摇杆完全向左拉,它将显示-1。
我们可以创建自己的轴来映射其他基于游戏手柄压力的常见控制,但对我们游戏来说,默认的轴就足够了。要检测鼠标移动,请按照以下步骤操作:
- 在
Update函数内部,紧挨着移动的if语句,使用Input.GetAxis函数,如以下截图所示,将这一帧鼠标移动的值存储到一个变量中:

图 6.13:获取鼠标的水平移动
- 使用
transform.Rotate函数来旋转角色。这个函数接收 x、y 和 z 轴上要旋转的度数。在这种情况下,我们需要水平旋转,所以我们将使用鼠标移动值作为 y 轴旋转,如下一个截图所示:

图 6.14:根据鼠标移动水平旋转对象
- 如果你保存并测试这段代码,你会注意到角色会旋转,但速度非常快或非常慢,这取决于你的电脑。记住,这类值需要可配置,所以让我们在编辑器中创建一个
rotationSpeed字段来配置玩家的速度:

图 6.15:速度和旋转速度字段
- 现在,我们需要将鼠标移动值乘以速度,因此,根据
rotationSpeed的值,我们可以增加或减少旋转量。例如,如果我们设置旋转速度为0.5,将这个值乘以鼠标移动将使对象以之前速度的一半旋转,如下面的截图所示:

图 6.16:将鼠标移动乘以旋转速度
- 保存代码并返回编辑器设置旋转速度值。如果你不这样做,对象将不会旋转,因为浮点类型字段的默认值是
0:

图 6.17:设置旋转速度
实现旋转的视觉脚本添加将看起来像这样:

图 6.18:在视觉脚本中旋转
这里要注意的第一件事是Sequence节点的使用。一个输出引脚只能连接到另一个节点,但在这个情况下,On Update需要执行两件不同的事情,旋转和移动,每件都是独立的。Sequence是一个节点,它将依次执行所有输出引脚,而不考虑每个节点的结果。你可以在Steps输入框中指定输出引脚的数量;在这个例子中,两个就足够了。
在输出引脚0,第一个引脚,我们添加了旋转代码,鉴于它基本上与移动代码相同,只是节点略有不同(Rotate (X, Y, Z)和GetAxis)。然后,我们将If节点连接到输出引脚 1,这是我们在本节开头所做的。这将导致旋转先执行,然后是移动。
关于我们之前提到的限制,基本上是因为我们无法同时执行前进和后退旋转,因为如果按下前进移动键,第一个If语句将为真。由于后退键旋转是在假输出引脚上检查的,所以在这种情况下它们不会被检查。当然,作为我们的第一个移动脚本,可能已经足够了,但考虑横向移动。如果我们继续使用True和False输出引脚来链接If语句,我们将面临只能在一个方向上移动的情况。因此,我们不能组合例如Forward和Right来斜向移动。
解决这个问题的简单方法是将If节点放在序列中而不是链接它们,这样所有的If节点都会被检查,就像原始的 C#一样。你可以在下一张图片中看到一个例子:

图 6.19:序列化 If 语句
在这里需要考虑的是,可以通过右键单击连接它们的线条两端圆形引脚来移除If语句和任何类型的节点的链接。现在我们已经完成了我们的移动脚本,我们需要通过探索 Delta 时间概念来对其进行细化,以便在每台机器上都能正常工作。
理解 Delta 时间
Unity 的Update循环以计算机能够达到的速度执行。你可以在 Unity 中指定所需的帧率,但能否实现这完全取决于你的电脑能否达到这个速度,这又取决于许多因素,而不仅仅是硬件,所以你不能期望总是有稳定的 FPS。你必须编写代码来处理所有可能的情况。我们当前的脚本是以每帧一定的速度移动的,这里的每帧部分很重要。
我们已经将移动速度设置为 0.1,所以如果我的电脑以 120 FPS 运行游戏,玩家将每秒移动 12 米。那么,在游戏以 60 FPS 运行的电脑上会发生什么呢?正如你可能猜到的,它将只以每秒 6 米的速度移动,使得我们的游戏在不同电脑上具有不一致的行为。这就是 Delta 时间发挥作用的地方。
时间差是一个告诉我们自上一帧以来过去了多少时间的值。这个时间很大程度上取决于我们的游戏图形、实体数量、物理体、音频以及无数将决定你的电脑处理帧速度的方面。例如,如果你的游戏以 10 FPS 运行,这意味着在一秒钟内,你的电脑可以处理Update循环 10 次,这意味着每个循环大约需要0.1秒;在帧中,时间差将提供这个值。在下一张图中,你可以看到一个例子,4 个帧处理不同时间,这在现实生活中可能会发生:

图 6.20:游戏不同帧中的时间差值变化
在这里,我们需要编写代码以将移动的每帧部分改为每秒;我们需要在不同电脑上保持每秒一致的移动。实现这一点的办法是按比例移动到时间差:时间差值越高,帧越长,移动应该越远,以匹配自上次更新以来经过的真实时间。我们可以用0.1米每秒来考虑我们的speed字段当前值;我们的时间差说0.5意味着半秒已经过去,所以我们应该移动一半的速度,0.05。
经过两帧一秒后,帧移动的总和(2 x 0.05)与目标速度0.1相匹配。时间差可以解释为已经过去的一秒的百分比。
要让时间差影响我们的移动,我们只需在每一帧将速度乘以时间差即可,因为时间差可能每帧都不同,所以让我们这样做:
- 我们使用 Time.deltaTime 来访问时间差。我们可以通过在每次 Translate 中乘以时间差来开始影响移动:

图 6.21:将速度乘以时间差
- 我们可以通过链式连接鼠标和速度乘法来对旋转速度做同样的事情:

图 6.22:将时间差应用于旋转代码
- 如果你保存并播放游戏,你会注意到移动会比之前慢。这是因为现在
0.1是每秒的移动量,意味着每秒 10 厘米,这相当慢;尝试增加这些值。在我的情况下,速度为10,旋转速度为180就足够了,但旋转速度取决于玩家的偏好灵敏度,这可以配置,但让我们留到以后再说。
旋转的可视化脚本更改将看起来像这样:

图 6.23:将时间差应用于旋转可视化脚本
对于移动,你可以轻松地从这个例子中推断出来,或者记得检查 GitHub 上的项目。我们只是简单地连接了另一个乘法节点和获取时间差。
我们刚刚学习了如何混合 Unity 的输入系统,它告诉我们关于键盘、鼠标和其他输入设备的状态,以及基本的Transform移动函数。这样,我们可以开始使我们的游戏感觉更加动态。
现在我们已经完成了玩家的移动,让我们讨论如何使用Instantiate函数让玩家射击子弹。
实现生成
我们已经在编辑器中创建了大量的对象来定义我们的关卡,但一旦游戏开始,根据玩家的操作,必须创建新的对象以更好地适应玩家交互生成的场景。敌人可能过一段时间后出现,或者必须根据玩家的输入创建子弹;即使敌人死亡,也有可能生成一个道具。这意味着我们无法事先创建所有必要的对象,而应该动态地创建它们,这通过脚本完成。
在本节中,我们将检查以下生成概念:
-
生成对象
-
定时动作
-
销毁对象
我们将开始看到 Unity 的Instantiate函数,它允许我们在运行时创建 Prefab 的实例,例如在按下一个键时,或者以基于时间的模式,例如让我们的敌人每隔一定时间发射一次子弹。此外,我们还将学习如何销毁这些对象,以防止场景因为处理过多的对象而开始表现不佳。
让我们从根据玩家输入如何射击子弹开始。
生成对象
要在运行时或播放模式下生成一个对象,我们需要描述该对象,它具有哪些组件,以及其设置和可能的子对象。你可能在这里想到了 Prefab,你是对的;我们将使用一个指令来告诉 Unity 通过脚本创建 Prefab 的实例。记住,Prefab 的实例是基于 Prefab 创建的对象——基本上是原始对象的克隆。
我们将从射击玩家的子弹开始,所以首先让我们按照以下步骤创建子弹 Prefab:
-
在 GameObject | 3D Object | 球体中创建一个球体。如果你想,可以用另一个子弹模型替换球体网格,但在这个例子中我们将保持球体。
-
将球体重命名为
Bullet。 -
通过点击项目窗口的+按钮,选择材质选项,并将其命名为
Bullet来创建一个材质。请记住将其放置在Materials文件夹内。 -
在材质中勾选发射复选框,并将发射贴图和基础贴图颜色设置为红色。记住,发射颜色会使子弹发光,尤其是在我们的后期处理体积中带有光晕效果:

图 6.24:创建带有发射颜色的红色子弹材质
-
通过将材质拖动到球体上来应用材质。
-
将缩放设置为更小的值——
0.3, 0.3, 0.3在我的情况下是有效的。 -
创建一个名为
ForwardMovement的脚本,使子弹以固定的速度不断向前移动。你可以用 C#和视觉脚本创建它,但为了简单起见,我们在这个例子中只使用 C#。我建议你先自己尝试解决这个问题,然后在下一步查看带有解决方案的截图,作为一个小挑战来回顾我们之前看到的运动概念。如果你不记得如何创建脚本,请查看第五章,C#和视觉脚本简介,并查看前面的部分以了解如何移动对象。
-
下一个截图显示了脚本应该是什么样子:

图 6.25:一个简单的 ForwardMovement 脚本
- 将脚本(如果尚未添加)添加到子弹中,并将速度设置为合适的值。通常,子弹比玩家移动得快,但这取决于你想要获得的游戏体验。在我的情况下,
20就足够了。通过将子弹放置在玩家附近并玩游戏来测试它:

图 6.26:子弹中的 ForwardMovement 脚本
- 将子弹
GameObject实例拖到Prefab文件夹中创建一个子弹预制件。记住,预制件是一个资产,它描述了创建的子弹,就像创建子弹的蓝图:

图 6.27:创建预制件
- 从场景中移除原始子弹;当玩家按下键时(如果有的话),我们将使用预制件来创建子弹。
现在我们已经有了子弹预制件,是时候在玩家按下键时实例化它(克隆它)了。为此,请按照以下步骤操作:
-
创建并添加一个名为
PlayerShooting的脚本到玩家的GameObject中,并打开它。 -
我们需要一种方法让脚本能够访问预制件,以便知道从我们可能在我们项目中拥有的几十个预制件中选择哪一个。我们脚本所需的所有数据,这些数据取决于期望的游戏体验,都是以字段的形式存在的,例如迄今为止使用的速度字段。因此,在这种情况下,我们需要一个
GameObject类型的字段——一个可以引用或指向特定预制件的字段,这可以通过编辑器来设置。 -
添加字段代码看起来像这样:

图 6.28:预制件参考字段
如你所猜,我们可以使用GameObject类型来不仅引用预制件,还可以引用其他对象。想象一下,一个敌人 AI 需要引用玩家对象来获取其位置,使用GameObject来连接这两个对象。这里的技巧是考虑到预制件只是存在于场景外的普通GameObject;你无法看到它们,但它们在内存中,准备被复制或实例化。你只能通过在场景中放置的副本或实例来看到它们,就像我们迄今为止所做的那样,通过脚本或编辑器。
- 在编辑器中,点击属性右侧的圆圈并选择
BulletPrefab。另一种选择是将BulletPrefab 直接拖到属性上。这样,我们告诉我们的脚本要射击的子弹将是那个。记住,要拖动 Prefab 而不是场景中的子弹(那个现在应该已经被删除了):
图 6.29:设置 Prefab 引用以指向子弹
- 当玩家按下左鼠标按钮时,我们将射击子弹,所以请在 Update 事件函数中放置适当的 if 语句来处理它,就像下一个截图所示:

图 6.30:检测左鼠标按钮的压力
-
你会注意到这次,我们使用了 GetKeyDown 而不是 GetKey,前者是一种检测键压力开始的确切帧的方法;这个 if 语句只会在那个帧执行其代码,直到键被释放并重新按下,它才不会再次进入。这是防止子弹在每一帧生成的一种方法,但为了好玩,你可以尝试使用 GetKey 来检查它会如何表现。此外,KeyCode.Mouse0 是属于左键点击的鼠标按钮编号,KeyCode.Mouse1 是右键点击,KeyCode.Mouse2 是中键点击。
-
使用 Instantiate 函数来克隆 Prefab,将对其的引用作为第一个参数传递。这将创建一个之前提到的 Prefab 的副本,并将其放置在场景中:
![图片]()
图 6.31:实例化 Prefab
如果你保存脚本并播放游戏,你会注意到当你按下鼠标时,会生成子弹,但可能不是你期望的位置。如果你看不到它,试着检查 Hierarchy 中的新对象;它会在那里的。这里的问题是我们没有指定期望的生成位置,我们有两种方法可以设置它,我们将在接下来的步骤中看到:
- 第一种方法是使用从
MonoBehaviour继承的transform.position和transform.rotation继承字段,这将告诉我们我们的当前位置和旋转。我们可以将它们作为Instantiate函数的第二个和第三个参数传递,这样它就会明白这是我们希望子弹出现的位置。记住,设置旋转很重要,这样子弹就会面向与玩家相同的方向,以便它能够那样移动:

图 6.32:在我们的位置和旋转中实例化 Prefab
- 第二种方法是通过使用
Instantiate的前一个版本,但保存函数返回的引用,这将指向 Prefab 的克隆。这允许我们从中更改任何我们想要的内容。在这种情况下,我们需要以下三行;第一行将实例化和捕获克隆引用,第二行将设置位置,第三行设置旋转。我们还将使用克隆的transform.position字段,但这次使用=(赋值)运算符来更改其值:

图 6.33:在特定位置实例化 Prefab 的较长版本
记住,您可以在 前言 中链接的项目 GitHub 仓库查看完整的脚本。现在您可以使用其中一个版本保存文件并尝试射击。
如果您尝试到目前为止的脚本,您应该会看到子弹在玩家的位置生成,但在我们的情况下,它可能是在地板上。这里的问题是玩家的角色枢轴就在那里,通常,每个类人角色都有枢轴在他们的脚上。我们有几种方法可以解决这个问题。最灵活的一种是创建一个 射击点,它是玩家的一个空 GameObject 子对象,放置在我们想要子弹生成的位置。我们可以使用该对象的位置而不是玩家的位置,通过以下步骤:
-
在 GameObject | 创建空对象 中创建一个空
GameObject。将其重命名为ShootPoint。 -
将它作为玩家的 GameObject 的子对象,放置在您想要子弹出现的位置,可能稍微高一点和更向前一些:

图 6.34:放置在角色内部的空 ShootPoint 对象
- 如往常一样,要访问另一个对象的数据,我们需要一个对该对象的引用,例如 Prefab 引用,但这次它需要指向我们的射击点。我们可以创建另一个
GameObject类型字段,但这次拖动ShootPoint而不是 Prefab。脚本和对象集将如下所示:


图 6.35:Prefab 和 ShootPoint 字段以及它们在编辑器中的设置
- 我们可以通过使用
ShootPoint的transform.position字段再次访问其位置,如下面的截图所示:

图 6.36:Prefab 和 ShootPoint 字段以及它们在编辑器中的设置
ForwardMovement 的视觉脚本版本将看起来像这样:

图 6.37:使用视觉脚本的前进运动
PlayerShooting 将看起来像这样:

图 6.38:PlayerShooting 视觉脚本中的实例化
如您所见,我们添加了一个名为Player Shooting的新图的第二个脚本机器组件。我们还添加了一个新的变量,bulletPrefab,类型为GameObject,并将Bullet预制体拖动到其中,以及一个名为shootPoint的第二个GameObject类型变量,以便引用子弹的生成位置。脚本的其他部分基本上是 C#版本的对应物,没有太大差异。这里要强调的是,我们如何将Transform GetPosition和Transform GetRotation节点连接到shootPoint所属的GetVariable节点;这样,我们就可以访问射击点的位置和旋转。如果您没有指定,它将使用玩家的位置和旋转,在我们的模型中,这位于玩家角色的脚下。
您会注意到现在使用鼠标射击和旋转存在问题;当移动鼠标进行旋转时,指针将超出游戏视图,当点击时,您会意外地点击编辑器,失去对游戏视图的焦点,因此您需要再次点击游戏视图以恢复焦点并再次使用输入。防止这种情况的一种方法是游戏时禁用光标。为此,请按照以下步骤操作:
-
将一个
Start事件函数添加到我们的Player Movement Script脚本中。 -
将以下截图中的两行代码添加到您的脚本中。第一行将使光标可见,第二行将锁定光标在屏幕中间,这样它就不会离开游戏视图。考虑后者;当您切换回主菜单或暂停菜单时,您需要重新启用光标,以便鼠标可以点击 UI 按钮:

图 6.39:禁用鼠标光标
-
保存并测试此脚本。如果您想停止游戏,可以按Ctrl + Shift + P(在 Mac 上为Command + Shift + P)或按Esc键重新启用鼠标。这两个选项仅在编辑器中有效;在真实游戏中,您需要将
Cursor.visible重置为true,将Cursor.lockState重置为CursorLockMode.None。 -
视觉脚本等效的代码如下所示:

图 6.40:在视觉脚本中禁用鼠标光标
现在我们已经介绍了对象生成的基础知识,让我们通过结合定时器来查看一个高级示例。
定时动作
虽然与生成不完全相关,但通常一起使用,定时动作是视频游戏中的常见任务。想法是安排稍后发生某事;也许我们希望子弹过一段时间后销毁以防止内存溢出,或者我们想要控制敌人的生成速率或它们应该何时生成。这正是本节将要做的,从第二个开始,即敌军波次。
策略是希望在游戏的各个时刻以一定的速率生成敌人;也许我们希望在 1 到 5 秒之间以每秒 2 个的速率生成敌人,总共生成 10 个敌人,并给玩家最多 20 秒的时间来完成它们,然后在 25 秒时编程下一波。当然,这很大程度上取决于你想要的精确游戏,你可以从一个像这样的想法开始,并在测试后对其进行修改以找到你想要的波系统工作的确切方式。在我们的案例中,我们将通过实现一个简单的波系统来应用计时。
首先,我们需要一个敌人,目前我们将简单地使用与玩家相同的 3D 模型,但添加一个 Forward Movement 脚本以使其向前移动;在本书的稍后部分,我们将为敌人添加 AI 行为。我建议你自己尝试创建这个 Prefab,并在尝试后查看以下步骤,以检查正确答案:
将下载的 Character FBX 模型拖到场景中以创建它的另一个实例,但这次将其重命名为 Enemy:
-
将为子弹创建的
ForwardMovement脚本添加到Enemy中,并暂时将其速度设置为10。 -
将
EnemyGameObject 拖到项目中以创建基于该 GameObject 的 Prefab;我们稍后需要生成它。请记住选择 Prefab Variant,这将使 Prefab 与原始模型保持链接,以便对模型所做的更改自动应用到 Prefab 上。 -
还要记得从场景中销毁原始的
Enemy。
现在,为了安排动作,我们将使用 Invoke 函数来创建计时器。它们很简单,但对于我们的需求来说足够了。让我们按照以下步骤使用它们:
-
在基地的一端创建一个空 GameObject 并将其命名为
Wave1a。 -
为其创建并添加一个名为
WaveSpawner的脚本。 -
我们的生成器需要四个字段:要生成的
EnemyPrefab、波的startTime、endTime和敌人的生成速率(每个生成之间应该有多少时间)。脚本和设置将类似于以下截图:

图 6.41:波生成器脚本的字段
我们将使用 InvokeRepeating 函数来安排一个周期性重复的自定义函数。你需要只安排一次重复;Unity 会记住这一点,所以不要在每一帧都做。这是使用 Start 事件函数的好理由。函数的第一个参数是一个字符串(引号之间的文本),包含要周期性执行的另一个函数的名称,与 Start 或 Update 不同,你可以随意命名该函数。第二个参数是开始重复的时间,即我们的 startTime 字段。最后,第三个参数是函数的重复速率——每次重复之间需要多少时间——这是 spawnRate 字段。你可以在下一个截图中找到如何调用该函数,以及自定义的 Spawn 函数:

图 6.42:调度一个重复的 Spawn 函数
- 在
Spawn函数内部,我们可以像我们知道的,使用Instantiate函数来放置生成代码。想法是以一定的速率调用这个函数来生成每个调用一个敌人。这次,生成位置将与生成器相同,所以请小心放置:

图 6.43:在 Spawn 函数中实例化
如果你通过将 Prefab 的startTime和spawnRate字段设置为大于 0 的值来测试这个脚本,你会注意到敌人会开始生成但永远不会停止,你可以看到我们到目前为止还没有使用endTime字段。想法是调用CancelInvoke函数,这是我们用来取消所有InvokeRepeating调用的一个函数,但过一段时间后。我们将使用Invoke函数延迟执行CancelInvoke,它的工作方式与InvokeRepeating类似,但这个函数只执行一次。在下一张截图,你可以看到我们如何在Start中添加一个Invoke调用到CancelInvoke函数,使用endTime字段作为执行CancelInvoke的时间。这将延迟执行CancelInvoke,取消第一个生成 Prefab 的InvokeRepeating调用:

图 6.44:使用 CancelInvoke 取消 Spawn 重复调度
这次,我们使用了Invoke来延迟调用CancelInvoke。我们没有创建一个自定义函数,因为CancelInvoke不接受参数。如果你需要安排一个带有参数的函数,你需要创建一个没有参数的包装函数,该函数调用所需的函数并安排它,就像我们在Spawn中所做的那样,那里的唯一目的是用特定的参数调用Instantiate。
- 现在你可以保存并设置一些真实值到我们的生成器。在我的例子中,我使用了以下截图所示的内容:

图 6.45:在游戏第 1 秒到第 5 秒内,每 0.5 秒生成 2 个敌人
你应该看到敌人一个接一个地生成,并且由于它们向前移动,它们将形成一排敌人。这种行为将在 AI 之后改变。现在,视觉脚本版本将看起来像这样:

图 6.46:在视觉脚本中生成敌人
虽然我们可以在视觉脚本中使用InvokeRepeating方法,但在这里我们可以看到视觉方法的一些好处,因为它有时比编码更具灵活性。在这种情况下,我们在Start的开始处使用了Wait For Seconds节点,这个节点基本上会暂停流程的执行几秒钟。这将创建原始脚本中的初始延迟;这就是为什么我们使用了startTime作为Delay的量。
现在,在等待之后,我们使用了一个For循环;在这个例子中,我们改变了脚本的概念,因为我们想要生成特定数量的敌人,而不是在一段时间内生成。For循环本质上是一个经典的For循环,它将重复连接到Body输出引脚的内容,重复次数由Last输入引脚指定的次数。
我们将那个引脚连接到一个变量,以控制我们想要生成的敌人数量。然后,我们将一个Instantiate连接到For循环的Body输出引脚,以实例化我们的敌人,然后是一个Wait For Seconds,在循环可以继续生成敌人之前停止流动一段时间。
有趣的是,如果你现在玩游戏,你将在控制台收到一个错误,看起来像这样:

图 6.47:使用等待节点时的错误
你甚至可以回到图编辑器,看到冲突的节点将以红色突出显示:

图 6.48:导致错误的节点
这里的问题是,为了让“等待秒数”节点正常工作,你需要将“开始”事件标记为协程。这基本上允许事件暂停一段时间,然后稍后继续。这个概念在 C#中同样存在,但由于在视觉脚本中实现起来比在 C#中简单,我们决定在这里采用这种方法。
要解决这个问题,只需选择“开始”事件节点,并在“脚本图”编辑器左侧的图检视器面板中勾选协程复选框。如果你看不到它,请考虑在编辑器的左上角点击信息按钮(带i的圆圈)。
协程是一个可以被暂停并在稍后继续执行的功能,这正是“等待”节点所做的事情。协程也存在于MonoBehaviours中,但现在让我们保持简单。

图 6.49:将开始标记为协程
现在我们已经讨论了时间和生成,让我们讨论时间和Destroy,以防止我们的子弹在内存中永远存在。
销毁对象
我们可以使用Destroy函数来销毁对象实例。想法是让子弹有一个脚本,在一段时间后安排自己的自动销毁,以防止它们永远存在于内存中。我们将通过以下步骤创建脚本:
-
选择“子弹”的预制件,并添加一个名为
Autodestroy的脚本,就像你使用添加组件 > 新脚本选项对其他对象所做的那样。这次,脚本将被添加到预制件中,你生成的每个预制件实例都将拥有它。 -
你可以使用如图所示的
Destroy函数,在Start中一次性销毁对象:

图 6.50:在开始时销毁对象
Destroy函数期望将要销毁的对象作为第一个参数,在这里,我们使用gameObject引用;这是一种指向我们的脚本放置的 GameObject 并销毁它的方法。如果你使用this指针而不是GameObject,我们只会销毁我们创建的Autodestroy组件。
当然,我们不想子弹一出现就被销毁,因此我们需要延迟销毁。你可能正在考虑使用Invoke,但与 Unity 中的大多数函数不同,Destroy可以接收第二个参数,即等待销毁的时间。
- 创建一个延迟字段,用作
Destroy的第二个参数,如下一张截图所示:

图 6.51:使用字段配置销毁对象的延迟
-
将
delay字段设置为适当的值;在我的情况下,5 就足够了。现在检查子弹过一段时间后如何从 Hierarchy 中移除,以查看其消失。 -
视觉脚本中的等效操作将如下所示:

图 6.52:在视觉脚本中销毁
关于这个版本,注意我们如何使用组件销毁(Obj, T)版本的Destroy节点,它包括延迟时间。
寻找Object Pool概念,这是一种回收对象而不是不断创建对象的方法;你会了解到有时创建和销毁对象并不那么高效。
现在,我们可以随意创建和销毁对象,这在 Unity 脚本中是非常常见的。在下一节中,我们将讨论如何修改我们迄今为止所编写的脚本以支持新的 Unity 输入系统。
使用新的输入系统
我们一直在使用Input类来检测被按下的按钮和轴,对于我们的简单使用来说,这已经足够了。但是,默认的 Unity 输入系统在扩展性方面有其局限性,无法支持新的输入硬件和映射。
在本节中,我们将探讨以下概念:
-
安装新的输入系统
-
创建输入映射
-
在脚本中使用映射
让我们开始探索如何安装新的输入系统。
安装新的输入系统
要开始使用新的输入系统,它需要像我们迄今为止安装的任何其他包一样安装,使用包管理器。这个包只是叫做Input System,所以继续像往常一样安装。在这种情况下我们使用的是版本 1.4.2,但当你阅读这一章时可能有一个更新的版本可用。

图 6.53:安装新的输入系统包
默认情况下,当你安装输入系统时,它将弹出一个窗口,如以下图像所示,提示你启用新的输入系统。如果出现这种情况,只需点击是并等待 Unity 重新启动:

图 6.54:切换活动输入系统
如果由于某种原因没有出现,另一种选择是编辑 | 项目设置,然后转到玩家 | 其他设置 | 配置,将活动输入处理属性设置为输入系统包(新)。
有一个名为Both的选项可以同时启用两者,但让我们坚持只使用一个。

图 6.55:切换活动输入系统
现在我们已经安装并设置了系统,让我们来探索如何创建所需的输入映射。
创建输入映射
新系统有一种直接请求按钮或摇杆当前状态的方法,无论是游戏手柄、鼠标、键盘还是我们拥有的其他设备,就像我们之前在旧输入系统中所做的那样。但这样做会阻止我们使用系统最好的功能之一,即输入映射。
输入映射的概念是将输入动作从物理输入中抽象出来。不是考虑空格键、游戏手柄的左摇杆或鼠标的右键点击,而是用动作来思考,比如移动、射击或跳跃。在代码中,你会询问是否按下了“射击”按钮,或者“移动”轴的当前值,就像我们处理鼠标轴旋转一样。虽然之前的系统支持一定程度的输入映射,但新输入系统的输入映射功能更强大,配置也更简单。
| 动作 | 映射 |
|---|---|
| 射击 | 左鼠标按钮、左控制键、游戏手柄的 X 按钮 |
| 跳跃 | 空格键、游戏手柄的 Y 按钮 |
| 水平移动 | A 和 D 键、左右箭头、游戏手柄的左摇杆 |
图 6.56:输入映射表的示例
这个想法的强大之处在于,实际触发这些动作的键或按钮可以在 Unity 编辑器中配置,允许任何游戏设计者更改控制整个游戏的精确键,而不需要更改代码。
我们甚至可以将多个按钮映射到同一个动作,甚至来自不同的设备,这样我们就可以让鼠标、键盘和游戏手柄触发同一个动作,极大地简化了我们的代码。另一个好处是,用户还可以使用我们添加到游戏中的某些自定义 UI 重新绑定键,这在 PC 游戏中非常常见。
开始创建输入映射最简单的方法是通过玩家输入组件。这个组件,正如其名所示,代表特定玩家的输入,允许我们在游戏中的每个玩家都有一个,以支持分屏多人游戏,但让我们专注于单人游戏。将此脚本添加到我们的玩家中,将允许我们使用创建动作...按钮创建默认的输入映射资产。这个资产,作为一个材料,可以被多个玩家使用,因此我们修改它,它将影响所有玩家(例如,添加“跳跃”输入映射):

图 6.57:使用玩家输入组件创建输入动作资产
点击该按钮并保存资产位置在保存提示中后,你会看到以下屏幕:

图 6.58:默认输入映射文件
从这个资源中首先需要理解的部分是动作映射部分(左侧面板)。这允许我们为不同的情况创建单独的动作映射,例如,在像 GTA 这样的游戏中,为驾驶和步行控制创建动作映射。默认情况下,创建了玩家和用户界面映射,以区分玩家控制和通过用户界面导航的映射。如果你再次检查玩家输入组件,你会看到默认映射属性设置为玩家,这意味着我们只会关注在这个 GameObject 中控制输入映射的玩家;任何按下的 UI 动作都不会被考虑。我们可以在运行时随意切换活动映射,例如,在暂停菜单中禁用角色控制器输入,或者在车内切换到驾驶映射,使用相同的按钮但用于其他目的。
如果你选择左侧面板中的动作映射,你将在中部面板的动作列表中看到它包含的所有动作。在玩家的情况下,我们有移动、观察和射击映射,这正是我们将在游戏中使用的输入。记住,如果你需要使用+按钮添加更多,但现在让我们坚持使用默认的映射。当你从列表中选择任何动作时,你将在动作属性面板中看到它们的配置,即右侧的面板:

图 6.59:移动(左侧)和射击(右侧)动作配置
如你所见,有一个名为动作类型的属性,它将决定我们正在讨论哪种类型的输入。如果你在中部面板中选择移动,你可以看到它是一个值动作类型,控制类型为Vector2,这意味着它将返回 x 轴和 y 轴的值,水平和垂直的值——这是我们期望从任何游戏手柄的摇杆中得到的。在之前的系统中,我们从分离的 1D 轴(如鼠标 X和鼠标 Y轴)中获取这些值,但在这里它们被合并成一个变量以方便使用。另一方面,射击动作类型为按钮,它不仅能够检查其当前状态(按下或释放),还能进行检查,如检查它是否刚刚被按下或刚刚被释放,这是之前系统中GetKey、GetKeyDown和GetKeyUp的等价物。
现在我们已经了解了我们有哪些动作以及每个动作的类型,让我们讨论物理输入如何触发它们。你可以点击中部面板中每个动作左侧的箭头以查看其物理映射。让我们开始探索移动动作映射。
在这个例子中,我们有 4 个映射:
-
左侧摇杆 [游戏手柄]:游戏手柄的左侧摇杆
-
主要 2D 轴 [XR 控制器]:VR 控制器的摇杆
-
摇杆[操纵杆]:用于街机式操纵杆或甚至飞行操纵杆的主要摇杆
-
WASD:通过 W、A、S 和 D 键模拟摇杆的复合输入
如果你选择了其中任何一个,你可以检查它们的配置;让我们以左摇杆和 WASD 为例:

图 6.60:左摇杆映射(左)和 WASD 键映射(右)
在左摇杆的情况下,你可以看到允许你选择所有可能提供Vector2值(x 轴和 y 轴)的硬件物理控制的路径属性。在WASD键映射的情况下,你可以看到它是一个类型为2D 向量的复合绑定,正如之前所述,这允许我们使用其他输入(在这种情况下是键)来模拟 2D 轴。如果你展开中间面板中的WASD输入映射,你可以看到所有正在组合到这个 2D 轴的输入,并通过选择它们来查看它们的配置:

图 6.61:考虑的 WASD 复合 2D 轴的输入
在这种情况下,它不仅映射了 W、A、S 和 D 按钮,还包括了 4 个键盘箭头。每个映射都有一个路径来选择物理按钮,还有一个复合部分设置,允许我们指定这个输入将拉动模拟摇杆的方向。
就这样,我们只是刚刚触及了这个系统所能做到的表面,但现在让我们保持简单,并使用这些设置。记住,在项目的根目录中创建了一个与我们的游戏同名的新资产(在我们的例子中是SuperShooter)。你可以通过双击它来随时重新打开这个动作映射窗口。现在让我们看看我们如何在代码中使用这些输入。
在我们的脚本中使用映射
这个系统提供了几种检测输入状态的方法。玩家输入组件有一个行为属性,可以在一些可用模式之间切换。最简单的一个是称为发送消息的模式,我们将使用它,当按键被按下时,它将在我们的代码中执行方法。在这个模式下,映射中的每个动作都将有自己的事件,你可以在组件底部的工具提示中看到所有这些。随着你添加映射,将出现更多。

图 6.62:默认映射的所有输入事件
从列表中,我们需要三个,OnMove、OnLook和OnFire。我们可以像以下截图那样修改我们的PlayerMovement脚本以使用它们:

图 6.63:使用新输入系统的玩家移动
你首先会注意到的一个不同点是,我们不再像以前那样在Update方法中请求输入状态。相反,我们监听OnMove和OnLook事件,这些事件为我们提供了一个包含那些轴当前状态的InputValue参数。想法是每次这些轴的值发生变化时,这些事件都会执行,如果值没有变化,比如当玩家一直将摇杆推到最右边时,它们将不会执行。这就是为什么我们需要在movementValue和lookValue变量中存储当前值,以便在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.64:使用新输入系统的 PlayerShooting 脚本
在这种情况下,情况更简单,因为我们不需要在每一帧执行射击行为,我们只需要在输入被按下的那一刻执行某些操作,这正是OnFire事件将被执行的时刻。如果你需要检测键被释放的情况,你可以添加InputValue参数,就像我们在OnMove和OnLook中做的那样,并咨询isPressed属性:

图 6.65:获取按钮的状态
关于我们脚本的 Visual Script Machine 版本,首先,你需要通过转到Edit | Project Settings | Visual Scripting并点击Regenerate Nodes按钮来刷新Visual Script Node Library。如果你不这样做,你将看不到新的 Input System 节点:

图 6.66:重新生成支持新 Input System 的可视脚本节点
现在,PlayerShooting可视脚本将看起来像这样:

图 6.67:使用新输入系统实例化子弹
新的On Input System Event Button节点允许我们检测当动作按钮被按下时,并相应地做出反应。你可以在Input Action参数中选择特定的动作,甚至可以通过节点标题下方的选项使节点对按钮的压力、释放或保持状态做出反应。存在一个 bug,即Input Action属性可能不会显示任何选项;在这种情况下,尝试在图中删除并重新添加节点,并确保你已将ScriptMachine组件添加到具有PlayerInput组件的同一 GameObject 中。还要检查你是否已选择了玩家 GameObject。
关于移动,可以这样实现:

图 6.68:使用新 Input System 移动
在这个情况下,我们使用了On Input System Event Vector2节点。这次,我们使用了OnHold模式,这意味着,与 C#版本不同,它不会仅在轴变化时执行,而是在轴被按下的所有帧都会像Update一样执行;然而,这只会当用户按下摇杆时执行。节点的输出引脚是Vector2值,所以我们将其乘以speed变量(在玩家Variables组件中声明)和DeltaTime。最后,我们使用Vector2 GetX和Vector2 GetY节点在 x 和 z 轴上平移。当重新连接Multiply节点到新的Input System节点时,可能会遇到麻烦,因为返回类型与之前使用的节点不同(从单个float变为Vector2)。我建议直接删除此图中的所有节点,并重新构建以确保一切正常。
摘要
我们创建了我们的第一个真正的脚本,它提供了有用的行为。我们讨论了如何根据输入移动 GameObject,并通过脚本实例化预制体以根据游戏情况随意创建对象。此外,我们还看到了如何安排动作,在这种情况下是生成,但这可以用来安排任何事物。我们看到了如何销毁创建的对象,以防止对象数量增加到无法管理的水平。最后,我们探索了新的输入系统,以提供最大的灵活性来自定义我们游戏的输入。我们将在本书的后面部分使用这些动作来创建其他类型的对象,例如声音和效果。
现在您能够创建任何类型的移动或生成逻辑,确保所需的对象在必要时被销毁。您可能会认为所有游戏都以相同的方式移动和创建射击系统,尽管它们相似,但能够创建自己的移动和射击脚本允许您自定义游戏中的这些方面,使其按预期行为并创建您所寻找的精确体验。
在下一章中,我们将讨论如何检测碰撞以防止玩家和子弹穿过墙壁以及更多内容。
第七章:物理碰撞和健康系统
随着游戏试图模拟现实世界的表现,一个重要的模拟方面是物理,它决定了物体如何移动以及它们如何相互碰撞,例如玩家与墙壁的碰撞,或者子弹与敌人的碰撞。由于碰撞后可能发生的无数反应,物理可能难以控制,因此我们将学习如何正确配置我们的游戏,以尽可能精确地创建物理效果。这将产生期望的街机移动感觉,并使真实的碰撞效果工作——毕竟,有时候,现实生活并不像电子游戏那样有趣!
在本章中,我们将探讨以下碰撞概念:
-
配置物理
-
检测碰撞
-
使用物理移动
首先,我们将学习如何正确配置物理设置,这是为了让我们的脚本能够检测到物体之间的碰撞,我们将学习使用新的 Unity 事件。所有这些都需要,以便检测我们的子弹何时触碰到敌人并对其造成伤害。然后,我们将讨论使用Transform移动(这是我们迄今为止所做的那样)和使用 Rigidbody 移动之间的区别,以及每个版本的优缺点。这将用于尝试不同的移动玩家方式,并让你决定你想要使用哪一种。让我们从讨论物理设置开始。
配置物理
Unity 的物理系统已经准备好覆盖广泛的可能的游戏应用,因此正确配置它对于获得期望的结果非常重要。
在本节中,我们将探讨以下物理设置概念:
-
设置形状
-
物理对象类型
-
碰撞过滤
我们将首先学习 Unity 提供的不同类型的碰撞体,然后学习不同的配置方法以检测不同类型的物理反应(碰撞和触发器)。最后,我们将讨论如何忽略特定物体之间的碰撞,以防止玩家子弹损坏玩家的情况。
设置形状
在本书的开头,我们了解到物体通常有两种形状,一种是视觉形状——基本上是 3D 网格——另一种是物理形状,即碰撞体——物理系统将使用它来计算碰撞。请记住,这个想法是让你拥有高度详细的可视模型,同时拥有简化的物理形状以提高性能。
Unity 有几种类型的碰撞体,所以在这里我们将回顾常见的几种,从原始类型开始,即盒子、球体和胶囊体。这些形状由于它们之间的碰撞是通过数学公式完成的,因此在性能上是最便宜的(与其他碰撞体如网格碰撞体不同,网格碰撞体允许你使用任何网格作为物体的物理体,但性能成本更高,并且有一些限制)。想法是,你应该使用原始类型来表示你的对象或它们的组合,例如,一架飞机可以用两个盒子碰撞体来完成,一个用于机身,另一个用于机翼。你可以在下面的屏幕截图中看到这个例子,其中你可以看到由原始形状制成的武器碰撞体:

图 7.1:复合碰撞体
无论如何,这并不总是必要的;如果我们想让武器直接掉到地上,可能一个覆盖整个武器的盒子碰撞体就足够了,考虑到这类碰撞不需要非常精确,从而提高性能。此外,有些形状即使通过原始形状的组合也无法表示,例如斜坡或金字塔,这时你唯一的解决方案是使用网格碰撞体,它需要一个 3D 网格用于碰撞,但鉴于其高性能的影响,我们在这本书中不会使用它们;我们将使用原始形状来解决所有的物理碰撞体。
现在,让我们向场景中添加必要的碰撞体,以便正确地计算碰撞。考虑到如果你使用了除了我的以外的 Asset Store 环境包,你可能已经拥有了带有碰撞体的场景模块;我将展示我在我的情况中需要做的工作,但尽量将这里的主要思想应用到你的场景中。要添加碰撞体,请按照以下步骤操作:
-
在基础中选择一个墙壁并检查对象及其可能的子对象是否有碰撞体组件;在我的情况下,我没有碰撞体。如果你检测到任何网格碰撞体,你可以保留它,如果你想要的话,但我建议你在下一步中将其移除并替换为另一个选项。想法是添加碰撞体,但我在这里发现的问题是,由于我的墙壁不是 Prefab 的实例,我需要为场景中的每个墙壁添加碰撞体。
-
一个选择是创建一个 Prefab 并将所有墙壁替换为 Prefab 的实例(推荐解决方案)或者只是选择层次结构中的所有墙壁(在 Mac 上按住Ctrl或Cmd点击它们),然后选择它们,使用添加组件按钮为它们添加碰撞体。在我的情况下,我将使用
Box Collider组件,它将调整碰撞体的尺寸以适应网格。如果它没有适应,你只需更改盒子碰撞体的大小和中心属性,以覆盖整个墙壁:

图 7.2:添加到墙上的盒子碰撞体
- 对角落、地板砖和任何其他会阻挡玩家和敌人移动的障碍物重复步骤 1和步骤 2。
现在我们已经为墙壁和地板添加了所需的碰撞器,我们可以继续处理玩家和敌人。我们将为他们添加胶囊碰撞器,这是在可移动角色中通常使用的碰撞器,因为其圆形底部将允许物体平滑地爬坡。水平圆形允许物体在角落中轻松旋转而不会卡住,以及其他该形状的便利性。您可能想要基于我们之前下载的角色之一创建一个敌人 Prefab,这样您就可以将碰撞器添加到该 Prefab 中。我们的玩家是场景中的一个简单 GameObject,所以您需要将碰撞器添加到那个 GameObject 上,但考虑也创建一个玩家 Prefab 以方便使用。
您可能会想给角色的骨骼添加几个盒子碰撞器以创建物体的逼真形状,虽然我们可以这样做以根据敌人被射击的身体部位施加不同的伤害,但我们只是在创建运动碰撞器;胶囊就足够了。在高级伤害系统中,胶囊和骨骼碰撞器将共存,一个用于运动,另一个用于伤害检测;但我们在游戏中将简化这一点。
此外,有时碰撞器无法很好地适应物体的视觉形状,在我的情况下,胶囊碰撞器与角色并不非常匹配。我需要通过设置以下截图中的值来调整其形状以匹配角色:中心设置为0,1,0, 半径设置为0.5,高度设置为2:

图 7.3:角色碰撞器
我们用球体创建的子弹已经有一个球体碰撞器,但如果您用另一个网格替换子弹的网格,您可能想要更改碰撞器。目前,我们游戏中不需要其他对象,所以现在所有对象都已经有了合适的碰撞器,让我们看看如何为每个对象设置不同的物理设置以启用适当的碰撞检测。
如果您检查地形组件,您会看到它有自己的碰撞器类型,即地形碰撞器。对于地形,这是唯一要使用的碰撞器。
物理对象类型
现在我们已经通过使对象在物理模拟中具有存在感为每个对象添加了碰撞器,是时候配置它们以获得我们想要的精确物理行为。我们有无数可能的设置组合,但我们将讨论一组常见的配置文件,这些配置文件涵盖了大多数情况。记住,除了碰撞器之外,我们在本书的开头看到了 Rigidbody 组件,它是将物理应用于对象的那一个。以下配置文件是通过结合碰撞器和 Rigidbody 设置完成的:
-
静态碰撞体:正如其名所示,这种碰撞体不应该移动,除了某些特定例外。大多数环境对象都属于这一类别,例如墙壁、地板、障碍物和地形。这类碰撞体只是没有
Rigidbody组件的碰撞体,因此它们存在于物理模拟中,但没有任何物理作用;它们不能被其他对象的碰撞移动,它们不会有物理作用,并且无论发生什么情况,它们的位置都会固定。请注意,这与编辑器右上角的静态复选框无关;那些是我们在后续章节中将要探索的系统(例如第十二章,使用通用渲染管道进行照明),因此如果需要,您可以将静态碰撞体与该复选框未选中。 -
物理碰撞体:这些是具有
Rigidbody组件的碰撞体,就像我们在本书第一部分所做的下落球体的例子。这些是完全由物理驱动的对象,具有重力并且可以通过力移动;其他对象可以推动它们,并且它们会执行您所期望的任何其他物理反应。您可以使用此功能用于玩家、手榴弹移动、下落箱子,或在像The Incredible Machine这样的高度基于物理的游戏中的所有对象。 -
运动学碰撞体:这些是具有
Rigidbody组件但已勾选是运动学复选框的碰撞体。它们不像静态碰撞体那样对碰撞和力有物理反应,但它们预期会移动,允许物理碰撞体在移动时正确处理与它们的碰撞。这些可以用于需要使用动画或自定义脚本移动的对象,例如移动平台。 -
触发静态碰撞体:这是一个常规静态碰撞体,但已勾选碰撞体的是触发复选框。区别在于运动学和物理对象会穿过它,但通过生成一个
触发事件,一个可以通过脚本捕获的事件,它告诉我们有东西在碰撞体内部。
这可以用来创建按钮或触发对象,在玩家穿过游戏中的某些事件区域时,例如生成一波敌人、开门或在该区域是玩家的目标时赢得游戏。请注意,常规静态碰撞体在穿过此类类型时不会生成触发事件,因为它们不应该移动。
- 触发运动学碰撞体:运动学碰撞体不会生成碰撞,因此它们会穿过任何其他对象,但它们会生成
触发事件,因此我们可以通过脚本进行响应。这可以用来创建可移动的奖励物品,当被触摸时,它们会消失并给我们加分,或者子弹会通过自定义脚本移动,没有物理作用,就像我们的子弹一样,但它们接触其他对象时会造成伤害。
当然,除了指定的配置外,还可以存在其他配置,用于满足某些游戏的具体游戏玩法要求,但具体还是要靠你自己去尝试所有可能的物理设置组合,看看它们是否适用于你的情况;所描述的配置将涵盖 99%的情况。
为了回顾之前的场景,我给你留下以下表格,显示所有类型碰撞器之间的接触反应。每个可以移动的配置都有一个行;请记住,静态配置不应该移动。每一列代表它们与其他类型碰撞时的反应,无表示对象将无效果地穿过,触发器表示对象将穿过但会引发触发器事件,而碰撞表示对象将无法穿过另一个对象:
| 与静态碰撞 | 与动态碰撞 | 与运动学碰撞 | 与触发静态碰撞 | 与触发运动学碰撞 | |
|---|---|---|---|---|---|
| 动态 | 碰撞 | 碰撞 | 碰撞 | 触发器 | 触发器 |
| 运动学 | 无 | 碰撞 | 无 | 触发器 | 触发器 |
| 触发运动学 | 触发器 | 触发器 | 触发器 | 触发器 | 触发器 |
图 7.4:碰撞反应矩阵
考虑到这一点,让我们开始配置场景对象的物理属性。
墙壁、角落、地板砖和障碍物应使用静态碰撞器配置,因此它们上没有Rigidbody组件,并且它们的碰撞器将不会勾选是触发器复选框:

图 7.5:地板砖的配置;请记住,静态复选框仅用于照明
玩家应移动并生成与对象的碰撞,因此我们需要它具有动态配置。此配置将与我们当前的运动脚本(我鼓励你测试)产生有趣的行为,尤其是在与墙壁碰撞时,所以它不会像你预期的那样表现。我们将在本章后面处理这个问题:

图 7.6:玩家上的动态设置
我们之前建议你创建的Enemy预制体将使用运动学配置,因为我们将在稍后使用 Unity 的 AI 系统移动此对象,所以这里不需要物理效果,并且由于我们希望玩家与之碰撞,我们需要在那里有一个碰撞反应,所以这里没有触发器:

图 7.7:敌人的运动学设置
对于Bullet预制体,它通过脚本进行简单的移动(只是向前移动),而不是通过物理。我们不需要碰撞;我们将编写代码,使子弹在接触任何东西时立即销毁,并(如果可能)损坏碰撞的对象,所以运动学触发器配置就足够了。我们将使用触发器事件来编写接触反应的脚本:

图 7.8:子弹的运动学触发设置;勾选了是触发器和是运动学
现在我们已经正确配置了对象,让我们来看看如何过滤某些对象类型之间不希望发生的碰撞。
过滤碰撞
有时候我们希望某些对象相互忽略,比如玩家射出的子弹,它们不应该与玩家本身发生碰撞。我们总可以在 C#脚本中使用if语句来过滤这些情况,检查被击中的对象是否来自敌方或任何你想要的过滤逻辑,但那时已经太晚了;物理系统已经浪费了资源去检查本不应该发生碰撞的对象之间的碰撞。这就是层碰撞矩阵能帮到我们的地方。
层碰撞矩阵听起来很吓人,但它只是物理系统的一个简单设置,允许我们指定哪些对象组应该与其他组发生碰撞。例如,玩家的子弹应该与敌人碰撞,敌人的子弹应该与玩家碰撞。在这种情况下,敌人的子弹会穿过敌人,但这是我们想要的。我们的想法是创建这些组,并将我们的对象放入其中,在 Unity 中,这些组被称为层。我们可以创建层,并将 GameObject(检查器顶部部分)的层属性设置为将对象分配到该组或层。请注意,你的层数量有限,所以尽量明智地使用它们。
我们可以通过以下方式实现:
- 前往编辑 | 项目设置,在其中,从左侧面板查找标签和层选项:

图 7.9:标签和层设置
- 在层部分,填写空白区域以创建层。我们将使用这个来处理子弹场景,所以我们需要四个层:
Player、Enemy、PlayerBullet和EnemyBullet:

图 7.10:创建层
- 在层次结构中选择
PlayerGameObject,从检查器的顶部部分更改层属性为Player。同时,将Enemy预制件更改为Enemy层。将显示一个窗口,询问你是否想同时更改子对象;选择是:

图 7.11:更改玩家和敌人预制件的层
-
在子弹的情况下,我们有一个问题;我们有一个预制件但有两个层,而预制件只能有一个层。我们有两个选择:通过脚本根据射击者更改层,或者有两个具有不同层的子弹预制件。为了简单起见,我将选择后者,并借此机会将另一种材质应用到敌人子弹上,使其看起来不同。
-
我们将创建玩家子弹的预制件变体。记住,变体是基于原始预制件的一个预制件,就像类继承一样。当原始预制件发生变化时,变体也会变化,但变体可以有不同的地方,这将使其变得独特。
-
将子弹预制件拖放到场景中以创建实例。
-
再次将实例拖动到
预制件文件夹中,这次在出现的窗口中选择预制件 变体选项。 -
将其重命名为
Enemy Bullet。 -
销毁场景中的预制实例。
-
创建一个与玩家子弹类似但颜色不同的第二个材质,并将其放置在敌人子弹预制件变体上。
-
选择敌人子弹预制件,将其层级设置为
EnemyBullet,并对原始预制件(PlayerBullet)执行相同的操作。即使你更改了原始预制件的层级,由于变体对其进行了修改,修改后的版本(或覆盖)将占优,允许每个预制件拥有自己的层级。
现在我们已经配置了层级,让我们配置物理系统以使用它们:
-
转到编辑 | 项目设置并查找物理设置(不是物理 2D)。
-
滚动直到你看到层级碰撞矩阵,一个半网格的复选框。你会注意到每一列和行都标注了层级的名称,所以每一行和列交叉处的复选框将允许我们指定这两个层级是否应该碰撞。在我们的例子中,我们将其配置如下,以便玩家子弹不会击中玩家或其他玩家子弹,敌人子弹不会击中敌人或其他敌人子弹:

图 7.12:使玩家子弹与敌人碰撞,敌人子弹与玩家碰撞
值得注意的是,有时过滤逻辑可能不会那么固定或可预测,例如,仅击中具有一定生命值的对象,没有隐形时间增益的对象,或者可以在游戏过程中改变且难以为所有可能的层级和组生成条件的对象。因此,在这些情况下,我们应该在触发或碰撞事件之后依赖手动过滤。
现在我们已经过滤了碰撞,接下来让我们通过下一节中的碰撞反应来检查我们的设置是否正常工作。
检测碰撞
如你所见,适当的物理设置可能很复杂且非常重要,但现在我们已经解决了这个问题,让我们通过以不同的方式反应接触并在这个过程中创建一个健康系统来使用这些设置吧。
在本节中,我们将探讨以下碰撞概念:
-
检测触发事件
-
修改其他对象
首先,我们将探讨 Unity 提供的不同碰撞和触发事件,以便通过 Unity 碰撞事件来反应两个对象之间的接触。这允许我们执行我们想要放置的任何反应代码,但我们将探讨如何使用GetComponent函数修改接触对象组件。
检测触发事件
如果对象配置得当,如前所述,我们可以得到两种反应:碰撞或触发。碰撞反应有一个默认效果,即阻止对象的移动,但我们可以通过脚本添加自定义行为;但触发器,除非我们添加自定义行为,否则不会产生任何明显的效果。无论如何,我们可以为两种可能的场景编写脚本,例如添加分数、减少生命值和输掉游戏。为此,我们可以使用物理事件套件。
这些事件分为两组,碰撞事件和触发事件,因此根据你的对象设置,你需要选择正确的组。两组都有三个主要事件,进入、持续和退出,告诉我们碰撞或触发何时开始(进入),它们是否仍在发生或仍在接触(持续),以及何时停止接触(退出)。例如,我们可以在进入事件中编写一个行为,比如在两个物体首次接触时播放声音,例如摩擦声,并在退出事件中停止播放。
让我们通过创建我们的第一个接触行为来测试这个功能:子弹在接触任何物体时被销毁。记住,子弹被配置为触发器,因此它们在接触任何物体时都会生成触发事件。你可以按照以下步骤操作:
-
在玩家子弹预制件上创建并添加一个名为
ContactDestroyer的脚本;由于敌人子弹预制件是其变体,它也将具有相同的脚本。 -
要检测触发器何时发生,例如使用开始和更新,创建一个名为
OnTriggerEnter的事件函数。 -
在事件内部,使用
Destroy(gameObject);行来使子弹在接触物体时销毁自身:

图 7.13:与物体接触时自动销毁
- 保存脚本,并将子弹射向墙壁以查看它们如何消失而不是穿过它们。在这里,我们没有碰撞,而是一个在接触时销毁子弹的触发器。因此,这样我们就可以确保子弹永远不会穿过任何东西,但我们仍然没有使用物理运动。
目前,我们不需要其他碰撞事件,但如果你需要,它们的工作方式类似;只需创建一个名为OnCollisionEnter的函数即可。
现在,让我们探索同一功能的另一个版本。它不仅告诉我们我们击中了某个物体,还告诉我们我们接触到了什么。我们将使用这个功能来使我们的接触销毁器也能销毁其他对象。为此,请按照以下步骤操作:
- 将
OnTriggerEnter方法签名替换为以下截图中的签名。这个签名接收一个Collider类型的参数,表示击中我们的确切碰撞器:

图 7.14:告诉我们我们与哪个对象发生碰撞的触发事件版本
- 我们可以使用
gameObject属性访问那个碰撞器的 GameObject。我们可以使用这个来销毁另一个对象,如下面的截图所示。如果我们只是使用Destroy函数并通过传递other变量,它将只销毁Collider组件:

图 7.15:销毁两个对象
- 保存并测试脚本。您会注意到子弹将销毁它接触到的所有东西。请记住验证您的敌人是否有胶囊碰撞器,以便子弹能够检测到与之的碰撞。
在视觉脚本中的等效版本如下所示:

图 7.16:使用视觉脚本销毁两个对象
如您所见,我们创建了一个On Trigger Enter节点并将其连接到两个Destroy节点。为了指定每个Destroy节点将销毁哪个对象,我们使用了两次Component: Get GameObject节点。右侧的一个没有连接到其左侧输入引脚的节点,这意味着它将返回当前执行此脚本的 GameObject(因此,节点左侧的This标签),在这种情况下,是子弹。对于第二个,我们需要将OnTriggerEnter节点右侧的Collider输出引脚连接到Get GameObject节点;这样我们指定我们想要获取子弹碰撞到的包含碰撞器的 GameObject。
现在,在我们的游戏中,我们不想让子弹在接触时销毁一切;相反,我们将让敌人和玩家拥有生命值;子弹将减少生命值,直到它达到 0,所以让我们看看如何做到这一点。
修改其他对象
为了让子弹损坏碰撞的对象,我们需要访问一个Life组件来更改其数量,因此我们需要创建这个Life组件来保存一个包含生命值的浮点字段。具有此组件的每个对象都将被视为可损坏的对象。要从我们的子弹脚本中访问Life组件,我们需要GetComponent函数来帮助我们。
如果您有一个 GameObject 或组件的引用,您可以使用GetComponent来访问对象中包含的特定组件(如果没有,它将返回null)。让我们看看如何使用该函数使子弹降低其他对象的生命值:
- 在玩家和敌人预制体中创建并添加一个名为
amount的public float字段的生命组件。记住在检查器中为两者设置值100(或您想要给予他们的任何生命值):

图 7.17:生命组件
-
从玩家子弹中移除
ContactDestroyer组件,这也会将其从Enemy Bullet Variant中移除。 -
将名为
ContactDamager的新脚本添加到敌人和玩家上。 -
添加一个
OnTriggerEnter事件,它接收一个参数other碰撞器,并仅添加一个Destroy函数调用来自动摧毁自身,而不是摧毁其他对象;我们的脚本不负责摧毁它,只是减少其生命值。 -
添加一个名为
damage的浮点字段,这样我们就可以配置对其他对象施加的伤害量。记得保存文件并设置一个值后再继续。 -
在对其他碰撞器的引用上使用
GetComponent来获取其Life组件的引用,并将其保存到一个变量中:

图 7.18:访问碰撞对象的 Life 组件
- 在减少对象的生命值之前,我们必须检查
Life引用不是null,这可能会发生在其他对象没有Life组件的情况下,例如墙壁和障碍物。想法是子弹在遇到任何东西时都会摧毁自己,如果它是一个包含Life组件的可伤害对象,则会减少其他对象的生命值。
在下面的屏幕截图中,你可以找到完整的脚本:

图 7.19:减少碰撞对象的寿命
-
在场景中放置一个敌人,并将其速度设置为
0以防止其移动。 -
在按 Play 键之前在层次结构中选择它,并开始射击它。
你可以在检查器中看到生命值的减少。你还可以按 Esc 键恢复鼠标控制,并在 Play 模式下选择对象,以在编辑器中查看运行时生命字段的更改。
现在,你会注意到生命值正在减少,但它会变成负数;我们希望当生命值低于 0 时对象能够自我摧毁。我们可以有两种方式实现这一点:一种是在 Life 组件中添加一个 Update 方法,它会检查每一帧的生命值是否低于 0,如果是,则摧毁自己。第二种方式是通过封装生命字段并在设置器中检查它来防止检查所有帧。我更倾向于第二种方式,但我们将实现第一种方式,以使脚本尽可能简单,便于初学者理解。
要这样做,请按照以下步骤操作:
-
向
Life组件添加Update方法。 -
添加
If条件来检查数量字段是否小于或等于0。 -
在
if条件为true的情况下添加Destroy。 -
完整的
Life脚本将如下所示:

图 7.20:Life 组件
- 保存并查看当
Life变为0时对象是如何被摧毁的。
Life 组件的视觉脚本版本看起来是这样的:

图 7.21:视觉脚本中的 Life 组件
脚本相当直接——我们检查我们的 Life 变量是否小于 0,然后像之前一样摧毁自己。现在,让我们检查一下 Damager 脚本:

图 7.22:视觉脚本中的 Damager 组件
这个版本与我们的 C#版本略有不同。乍一看,它看起来相同:我们像以前一样使用获取变量来读取生命值,然后我们使用减去节点从生命中减去伤害,计算结果成为新的生命值,使用设置变量节点来改变该变量的当前值。
我们在这里可以看到的第一个不同之处是缺少任何GetComponent节点。在 C#中,我们使用这个指令来获取碰撞对象的Life组件,以便读取和修改其数量变量,减少剩余的生命值。但在视觉脚本中,我们的节点图没有变量,所以我们不需要访问组件来读取它们。相反,我们知道敌人其变量组件中有一个名为Life的变量,我们使用获取变量节点,将其连接到我们撞击的碰撞器(On Trigger Enter的Collider输出引脚),因此本质上我们正在读取被撞击对象的Life变量值。
对于更改其值也是一样:我们使用设置值节点,将其连接到碰撞器,指定我们想要改变碰撞器对象的Life变量值,而不是我们自己的(因为我们甚至没有Life变量)。请注意,如果碰撞对象没有Life变量,这可能会引发错误,这就是为什么我们添加了对象有变量节点,它检查对象是否有一个名为Life的变量。如果没有,我们就什么也不做,这在与墙壁或其他不可破坏的对象碰撞时很有用。最后,我们让伤害者(在这个例子中是子弹)自动销毁自己。
可选地,当发生这种情况时,你可以实例化一个对象,例如声音、粒子或可能是一个增强道具。我将把这个留给你作为挑战。通过使用类似的脚本,你可以制作一个增加生命值的生命增强道具,或者一个通过访问PlayerMovement脚本并增加速度字段的加速增强道具;从现在开始,发挥你的想象力,使用这个来创建令人兴奋的行为。
现在我们已经探讨了如何检测碰撞并对其做出反应,让我们来探讨如何修复玩家在撞击墙壁时掉落的问题。
使用物理移动
到目前为止,玩家是唯一一个使用动态碰撞器配置文件移动的对象,并且它将使用物理引擎移动,实际上是通过自定义脚本使用 Transform API 来移动的。每个动态对象都应该使用 Rigidbody API 函数以物理系统更易理解的方式移动。因此,在这里我们将探讨如何移动对象,这次是通过 Rigidbody 组件。
在本节中,我们将检查以下物理移动概念:
-
应用力
-
调整物理
我们将首先看看如何通过力以正确的方式移动物体,并将这个概念应用到玩家的运动中。然后,我们将探讨为什么现实中的物理并不总是有趣的,以及我们如何调整物体的物理属性以获得更灵敏和吸引人的行为。
应用力
移动物体的物理准确方式是通过力,这会影响物体的速度。要应用力,我们需要访问Rigidbody而不是Transform,并使用AddForce和AddTorque函数分别移动和旋转。这些函数允许你指定要施加到位置和旋转每个轴上的力的大小。这种运动方式将具有完整的物理反应;力将累积在速度上以开始移动,并将受到阻力效应的影响,使速度逐渐减小,而且这里最重要的方面是它们将碰撞到墙壁,阻挡物体的路径。
要获得这种运动方式,我们可以做以下操作:
- 在
PlayerMovement脚本中创建一个Rigidbody字段,但这次将其设置为private,这意味着不要在字段中写入public关键字,这样它就会在编辑器中消失;我们将以另一种方式获取引用:

图 7.23:私有 Rigidbody 引用字段
-
注意,我们之所以将这个变量命名为
rb,是为了防止我们的脚本过于宽泛,使得书中代码的截图太小。建议你在脚本中正确地调用变量——在这种情况下,它应该命名为rigidbody。 -
在
Start事件函数中使用GetComponent,获取我们的 Rigidbody 并将其保存在字段中。我们将使用这个字段来缓存GetComponent函数的结果;每帧调用该函数来访问 Rigidbody 不是性能良好的做法。此外,你还可以注意到,GetComponent函数不仅可以用来检索其他对象的组件(如碰撞示例),也可以用来检索你自己的组件:

图 7.24:缓存 Rigidbody 引用以供将来使用
-
将
transform.Translate调用替换为rb.AddRelativeForce。这将调用 Rigidbody 的加力函数,特别是相对的加力函数,这将考虑物体的当前旋转。例如,如果你在z-轴(第三个参数)上指定一个力,物体将沿着其前进向量应用其力。 -
将
transform.Rotate调用替换为rb.AddRelativeTorque,这将应用旋转力:

图 7.25:使用 Rigidbody 力 API
- 检查玩家 GameObject 胶囊碰撞体是否没有与地板相交,而是略微超出它。如果玩家相交,运动将无法正常工作。如果是这种情况,请将其向上移动。
在视觉脚本版本中,变化是相同的;将变换和旋转节点替换为添加相对力和添加相对扭矩节点。添加相对力的一个例子如下:

图 7.26:使用 Rigidbody 力 API
并且对于这样的旋转:

图 7.27:使用 Rigidbody 扭矩 API
你可以看到,我们在这里也不需要使用GetComponent节点,因为仅仅使用添加相对力或扭矩节点就足以让视觉脚本理解我们想要在自己的 Rigidbody 组件上应用这些动作(再次解释This标签)。如果在任何其他情况下我们需要在除了我们自己的 Rigidbody 之外的其他 Rigidbody 上调用这些函数,我们则需要在那里使用GetComponent节点,但让我们稍后再探讨这一点。
现在,如果你保存并测试结果,你可能会发现玩家掉落,这是因为现在我们正在使用真实的物理,它包含地板摩擦,并且由于力作用于重心,这会使物体掉落。记住,从物理学的角度来看,你是一个胶囊;你没有腿可以移动,这就是标准物理不适合我们的游戏的地方。解决方案是调整物理以模拟我们需要的这种行为。
调整物理
为了让我们的玩家像在普通平台游戏一样移动,我们需要冻结某些轴以防止物体掉落。移除地面的摩擦力,并增加空气摩擦(阻力),使玩家在释放按键时自动减速。
要这样做,请按照以下步骤操作:
- 在
Rigidbody组件中,查看底部的约束部分,并检查冻结旋转属性的X和Z轴:

图 7.28:冻结旋转轴
-
这将防止物体向侧面掉落,但允许物体水平旋转。如果你不希望玩家跳跃,也可以冻结冻结位置属性的y轴,以防止碰撞时出现一些不希望的垂直移动。
-
你可能需要更改速度值,因为你从每秒米值更改为每秒牛顿值,这是添加力和添加扭矩函数的预期值。对我来说,速度设置为 1,000,旋转速度设置为 160 就足够了。
-
现在,你可能会注意到速度会随着时间的推移而大幅增加,旋转也是如此。记住,你正在使用力,这会影响你的速度。当你停止施加力时,速度会被保留,这就是为什么即使你不移动鼠标,玩家也会继续旋转。解决这个问题的方法是增加阻力和角阻力,这模拟了空气摩擦,并在没有施加力时分别减少移动和旋转。尝试一些你认为合适的值;在我的情况下,我使用了
2作为阻力和10作为角阻力,需要将旋转速度增加到150以补偿阻力的增加:

图 7.29:设置旋转和移动的空气摩擦
-
现在,如果你在触摸墙壁时移动,而不是像大多数游戏那样滑动,你的玩家会因为接触摩擦而粘附在障碍物上。我们可以通过创建一个
Physics Material(物理材质),一个可以分配给碰撞体以控制它们在这些场景中如何反应的资产来移除这种效果。 -
通过点击项目窗口中的+按钮并选择物理材质(不是 2D 版本)来开始创建一个。将其命名为
Player,并记得将其放入一个用于这些资产的文件夹中。 -
选择它,并将静态摩擦和动态摩擦设置为
0,将摩擦组合设置为最小,这将使物理系统选择两个碰撞物体之间的最小摩擦,这总是最小的——在我们的例子中,是零:

图 7.30:创建物理材质
- 选择玩家并将此资产拖动到胶囊碰撞体的材质属性:

图 7.31:设置玩家的物理材质
- 如果你现在玩游戏,你可能会注意到玩家移动的速度比以前快,因为现在地板上没有任何摩擦力,所以你可能需要减少移动力。
如你所见,我们需要弯曲物理规则以允许响应的玩家移动。你可以通过增加阻力和力来获得更多的响应性,这样速度就会更快地应用和减少,但这取决于你希望游戏拥有的体验。
一些游戏希望有即时的响应,没有速度插值,从一个帧到另一个帧从 0 到全速,反之亦然,在这些情况下,你可以直接根据你的意愿覆盖玩家的速度和旋转向量,或者甚至使用其他系统而不是物理,例如Character Controller组件,它为平台游戏角色有特殊的物理设置;但现在让我们保持简单。
摘要
每个游戏都以某种方式包含物理元素,无论是用于移动、碰撞检测,还是两者兼具。在本章中,我们学习了如何使用物理系统来实现这两者,注意适当的设置以确保系统正常工作,对碰撞做出反应以生成游戏玩法系统,以及以某种方式移动玩家,使其与障碍物发生碰撞,保持其物理上不准确的移动。我们利用这些概念来创建玩家的移动和子弹移动,并使子弹能够伤害敌人,但我们也可以重用这些知识来创建无数其他可能的游戏玩法需求,因此我建议你在这里尝试一下物理概念;你可以发现很多有趣的用例。
在下一章中,我们将讨论如何编程游戏的视觉方面,例如效果,并使用户界面对输入做出反应。
第八章:胜利和失败条件
现在我们已经有一个基本的游戏体验,是时候让游戏以胜利或失败的结果结束。一种常见的实现方式是通过具有监督一组对象以检测需要发生的情况的责任的独立组件,例如玩家生命值为 0 或所有波次都被清除。我们将通过管理器的概念来实现这一点,这些组件将管理和监控多个对象。
在本章中,我们将探讨以下管理器概念:
-
创建对象管理器
-
创建游戏模式
-
使用事件改进我们的代码
带着这些知识,你不仅能够创建游戏的胜利和失败条件,而且还能以正确的方式使用设计模式如单例和事件监听器来做到这一点。这些技能不仅对创建游戏的胜利和失败代码有用,而且对任何代码都很有用。首先,让我们从创建代表如分数或游戏规则等概念的管理器开始。
创建对象管理器
并非场景中的每个对象都应该是可以被看到、听到或与之碰撞的。一些对象也可以存在概念意义,而不是有形的东西。例如,想象你需要计算敌人的数量:你将把它保存在哪里?你还需要一个地方来保存玩家的当前分数,你可能认为它可以在玩家本身上,但如果玩家死亡并重生会发生什么呢?
数据将会丢失!在这种情况下,管理器的概念可以是我们最初游戏中解决问题的有用方式,让我们来探索一下。
在本节中,我们将看到以下对象管理器概念:
-
使用单例设计模式共享变量
-
在视觉脚本中共享变量
-
创建管理器
我们将首先讨论单例设计模式是什么以及它如何帮助我们简化对象的通信。有了它,我们将创建管理对象,使我们能够集中管理一组对象的信息,以及其他事情。让我们先从讨论单例设计模式开始。
使用单例设计模式共享变量
设计模式通常被描述为对常见问题的常见解决方案。在编写游戏代码时,你将不得不做出几个编码设计决策,但幸运的是,处理最常见情况的方法是众所周知的,并且有详细的文档。在本节中,我们将讨论最常见的设计模式之一,单例,它在简单项目中易于实现。
单例模式用于需要对象单个实例的情况,这意味着一个类不应该有多个实例,并且我们希望它易于访问(虽然不一定是必需的,但在我们的场景中很有用)。在我们的游戏中有很多情况可以应用这种模式,例如,ScoreManager,一个将保存当前得分的组件。在这种情况下,我们永远不会有多于一个的得分,因此我们可以利用单例管理器的优势。
一个好处是确保我们不会有重复的得分,这使我们的代码更不容易出错。此外,到目前为止,我们需要创建公共引用并通过编辑器拖动对象来连接两个对象,或者使用GetComponent来查找它们;然而,使用这种模式,我们将能够全局访问我们的单例组件,这意味着你只需在脚本中写下组件的名称,就可以访问它。最终,只有一个ScoreManager组件,所以通过编辑器指定哪一个是没有必要的。这类似于Time.deltaTime,负责管理时间的类——我们只有一个时间。
如果你是一个高级程序员,你现在可能正在考虑代码测试和依赖注入,你是对的,但请记住,我们到目前为止一直在尝试编写简单的代码,所以我们将坚持这个简单的解决方案。
让我们创建一个得分管理器对象,负责处理得分,以下是如何通过单例的示例:
-
创建一个空的 GameObject(GameObject | 创建空对象),并将其命名为
ScoreManager;通常,管理器被放在空对象中,与场景中的其他对象分开。 -
将名为
ScoreManager的脚本添加到该对象中,并添加一个名为amount的int字段,该字段将保存当前得分。 -
添加一个名为
instance的ScoreManager类型的字段,但向它添加static关键字;这将使变量全局,意味着只需写下它的名称就可以在任何地方访问它:

图 8.1:一个可以在代码的任何地方访问的静态字段
-
在
Awake方法中,检查instance字段是否不为null,如果是,则使用this引用将该ScoreManager实例设置为实例引用。 -
在
null检查if语句的else子句中,打印一条消息,指出存在第二个ScoreManager实例,该实例必须被销毁:

图 8.2:检查是否只有一个单例实例
策略是将对唯一的 ScoreManager 实例的引用保存在实例静态字段中,但如果用户不小心创建了两个具有 ScoreManager 组件的对象,这个 if 语句将检测到它,并通知用户错误,要求他们采取行动。在这种情况下,首先执行 Awake 的 ScoreManager 实例会发现没有设置实例(字段是 null),因此它会将自己设置为当前实例,而第二个 ScoreManager 实例会发现实例已经设置,并打印出消息。
记住,instance 是一个静态字段,在所有类之间共享,与常规引用字段不同,每个组件将有自己的引用,因此在这种情况下,我们向场景中添加了两个 ScoreManager 实例,它们将共享相同的实例字段。
为了稍微改进一下示例,最好有一种简单的方法来在游戏中找到第二个 ScoreManager。它将隐藏在 Hierarchy 的某个地方,可能很难找到,但我们通过以下方式解决这个问题:
- 将
print替换为Debug.Log。Debug.Log与print类似,但它有一个期望在控制台中点击消息时突出显示对象的第二个参数。在这种情况下,我们将传递gameObject引用,以便控制台突出显示复制的对象:

图 8.3:使用 Debug.Log 在控制台打印消息
点击日志消息后,包含复制的 ScoreManager 的 GameObject 将在 Hierarchy 中突出显示:

图 8.4:点击消息后突出显示的对象
- 最后,可以通过将
Debug.Log替换为Debug.LogError来在这里进行一点小小的改进,这将打印出消息,但会显示一个错误图标。在实际游戏中,控制台会有很多消息,通过突出显示错误信息而不是信息消息,可以帮助我们快速识别它们:

图 8.5:使用 LogError 打印错误消息
- 尝试运行代码并观察控制台中的错误消息:

图 8.6:控制台中的错误消息
下一步是在某个地方使用这个 Singleton,所以在这种情况下,我们将通过以下方式让敌人被击杀时获得分数:
-
将名为
ScoreOnDeath的脚本添加到Enemy预制件中,其中包含一个名为amount的int字段,该字段将指示敌人被击杀时将获得的分数。请记住,在编辑器中将值设置为非0,以便为预制件设置。 -
创建
OnDestroy事件函数,当此对象被销毁时,Unity 将自动调用此函数,在我们的例子中,是敌人:

图 8.7:OnDestroy 事件函数
请注意,当我们在更改场景或游戏退出时,OnDestroy 函数也会被调用,因此在这种情况下,我们可能会在更改场景时获得分数,这是不正确的。到目前为止,在我们的案例中这不是问题,但稍后在本章中,我们将看到一种防止这种情况的方法。
- 在
OnDestroy函数中通过编写ScoreManager.instance来访问单例引用,并将我们脚本的amount字段添加到单例的amount字段中,以便在敌人被击杀时增加分数:

图 8.8:完整的 ScoreOnDeath 组件类内容
- 在层级中选择
ScoreManager,点击 播放,然后消灭一些敌人以查看分数随着每次击杀而上升。请记住设置 Prefab 中ScoreOnDeath组件的amount字段。
如您所见,单例模式简化了访问 ScoreManager 的方式,并采取了安全措施以防止其自身的重复,这将有助于我们减少代码中的错误。需要注意的是,现在您可能会倾向于将一切事物都做成单例,例如玩家的生命或玩家的子弹,以便在创建游戏机制如升级时使生活变得更简单。
虽然这完全可行,但请记住,您的游戏将会发生变化,我的意思是变化会很大;任何真实的项目都会经历不断的变更。也许今天,游戏只有一个玩家,但也许在未来,您想添加第二个玩家或人工智能同伴,并且希望升级能够影响他们,所以如果您过度使用单例模式,您将难以处理这些场景以及更多的情况。也许未来的玩家同伴会尝试获取生命恢复物品,但主要玩家却被治愈了!
这里的关键是尽量少使用这种模式,除非您没有其他方法来解决问题。说实话,总有办法在不使用单例的情况下解决问题,但它们对初学者来说实施起来要困难一些,所以我更喜欢简化您的生活,让您保持动力。随着足够的练习,您将达到一个可以准备好提高您的编码标准的阶段。
现在,让我们讨论如何在 Visual Scripting 中实现这一点,鉴于它会有所不同,它值得拥有自己的部分。如果您对 Visual Scripting 方面不感兴趣,可以考虑跳过下面的部分。
使用 Visual Scripting 共享变量
视觉脚本有一个机制,用场景变量取代单例作为对象间共享变量的持有者:场景变量。如果你检查脚本图编辑器(我们编辑脚本节点窗口)中的左侧面板(显示我们对象变量的面板),你会注意到它将有许多标签页:图、对象、场景、应用和已保存。如果你看不到黑板面板,点击窗口左上角从左数第三个按钮,即i(信息)按钮右侧的按钮:

图 8.9:脚本图(变量)编辑器
到目前为止,当我们在任何对象的变量组件中创建一个变量时,我们实际上是在创建对象变量:属于对象的变量,在同一个对象的所有视觉脚本之间共享,但变量可以拥有的作用域不止这一种。以下是一个剩余作用域的列表:
-
图:只能由我们当前图访问的变量。其他脚本不能读取或写入该变量。这有助于保存内部状态,如 C#中的私有变量。
-
场景:可以被当前场景中所有对象访问的变量。当我们更改场景时,这些变量就会丢失。
-
应用:可以在游戏的任何部分任何时间访问的变量。这有助于将值从一个场景移动到另一个场景。例如,你可以在一个级别中增加分数,并在下一个级别中继续增加,而不是从 0 重新开始分数。
-
已保存:在游戏运行之间保持其值的变量。你可以保存持久数据,如玩家等级或库存以继续任务,或者更简单的事情,如用户在选项菜单中设置的音量(如果你创建了一个)。
在这种情况下,场景作用域是我们想要的,因为我们打算增加的分数将被场景中的多个对象访问(关于这一点稍后讨论),我们不希望它在重置级别以再次播放时持续存在;它需要在每个级别的每次运行和游戏中重新设置为 0。
要创建场景变量,你可以在编辑任何脚本图时,简单地选择黑板面板中的场景标签页,或者你也可以使用当你开始编辑任何图时自动创建的场景变量GameObject。这个对象是真正持有变量的那个,不能被删除。你会注意到它将有一个变量组件,就像我们之前使用的那样,但它还将有一个场景****变量组件,表示这些变量是场景变量。
在以下屏幕截图中,你可以看到我们如何简单地添加了分数变量到场景变量标签页,使其在我们的任何脚本图中都可以访问。

图 8.10:将场景变量添加到我们的游戏中
最后,对于增加分数的行为,我们可以在我们的敌人中添加以下图表。记住,像往常一样,只保留 C#或视觉脚本版本的脚本,不要两者都保留。

图 8.11:当此对象被销毁时增加分数
首先,这个脚本看起来与我们的 C#版本非常相似;我们添加我们的对象scoreToAdd变量(对象范围)并将其添加到节点中指定的整个场景的score变量中。您可以看到的主要区别是,这里我们使用的是OnDisable事件而不是OnDestroy。实际上,OnDestroy是正确的选择,但在当前版本的视觉脚本中存在一个错误,阻止它正常工作,所以我暂时替换了它。OnDisable的问题在于它会在对象被禁用时执行,而对象在被销毁之前可能被禁用,也可能在其他情况下被禁用(例如,使用Object Pooling,这是一种回收对象而不是不断销毁和实例化对象的方法),但到目前为止这对我们来说已经足够了。请在尝试此图表时首先考虑使用OnDestroy,以查看它是否在您的 Unity 或视觉脚本包版本中正常运行。
需要强调的是,使用Has Variable节点来检查分数变量是否存在。这样做是因为OnDisable可以在敌人被销毁的瞬间执行,或者当场景改变时执行,我们将在本章后面的内容中通过输赢屏幕来实现这一点。如果我们试图在那个时刻获取场景变量,由于场景变化涉及首先销毁场景中的每个对象,我们可能会在GameMode对象之前销毁Scene Variables对象,从而风险自己遇到错误。
如您现在可能已经注意到的,尽管视觉脚本(Visual Scripting)在大多数情况下与 C#非常相似,但有一种概念可以解决另一种无法解决的问题。现在我们知道了如何共享变量,让我们完成一些我们在游戏后期需要的其他管理器。
创建管理器
有时候,我们需要一个地方来汇总一组相似对象的信息,例如,EnemyManager,用于检查敌人的数量,并可能访问它们的数组以遍历并执行某些操作,或者可能是MissionManager,以便访问我们游戏中的所有活动任务。再次强调,这些情况可以被视为单例(Singletons),即不会重复出现的单个对象(在我们的当前游戏设计中),因此让我们创建我们在游戏中需要的那些,即EnemyManager和WaveManager。
在我们的游戏中,EnemyManager和WaveManager将仅用作保存现有敌人和波次的引用数组的存储位置,就像知道它们当前数量的方式一样。有方法可以搜索特定类型的所有对象来计算它们的数量,但这些函数成本高昂,除非你真的知道你在做什么,否则不建议使用。因此,拥有一个具有单独更新目标对象类型引用列表的单例将需要更多的代码,但性能会更好。此外,随着游戏功能的增加,这些管理器将具有更多功能以及与这些对象交互的帮助函数。
让我们从敌人管理器开始,执行以下操作:
-
向Enemy预设件添加一个名为
Enemy的脚本;这将是一个将此对象与EnemyManager连接的脚本。 -
创建一个名为
EnemyManager的空GameObject,并向其添加一个名为EnemiesManager的脚本。 -
在脚本内部创建一个名为
instance的public静态字段,类型为EnemiesManager,并在Awake中添加单例重复检查,就像我们在ScoreManager中做的那样。 -
创建一个名为
enemies的公共字段,类型为List<Enemy>:

图 8.12:敌人组件列表
C#中的列表表示一个动态数组,一个能够添加和删除对象的数组。你会在编辑器中看到你可以向此列表添加和删除元素,但请保持列表为空;我们将以另一种方式添加敌人。请注意,List位于System.Collections.Generic命名空间中;你将在我们脚本的开始处找到using语句。此外,考虑到你可以将列表设置为私有,并通过 getter 而不是公共字段将其暴露给代码;但像往常一样,我们现在将使代码尽可能简单:

图 8.13:使用 List 类所需的 using 语句
考虑到List是一个类类型,因此必须实例化,但由于此类型在编辑器中具有暴露支持,Unity 将自动实例化它。如果你想要一个非编辑器暴露的列表,如私有列表或常规非组件 C#类中的列表,你必须使用new关键字来实例化它。
C#中的列表在内部实现为一个数组。如果你需要一个链表,请使用LinkedList集合类型代替。
- 在
Enemy脚本的Start函数中,访问EnemyManager单例,并使用敌人列表的Add函数,将此对象添加到列表中。这将“注册”此敌人在管理器中的活动状态,以便其他对象可以访问管理器并检查当前敌人。Start函数在所有Awake函数调用之后被调用,这很重要,因为我们需要确保在敌人Start函数之前执行管理器的Awake函数,以确保已设置管理器实例。
我们用Start函数解决的问题被称为竞态条件,即当两段代码不能保证按相同顺序执行时,而Awake的执行顺序可能会因不同原因而改变。代码中有很多情况都会发生这种情况,所以请注意你代码中可能存在的竞态条件。此外,你可能考虑在这里使用更高级的解决方案,如懒初始化,这可以给你更好的稳定性,但为了简单起见,以及探索 Unity API,我们目前将使用Start函数的方法。
- 在
OnDestroy函数中,从列表中移除敌人以保持列表只更新活跃的敌人:

图 8.14:注册为我们自己的活跃敌人的敌人脚本
通过这种方式,我们现在有一个集中的地方可以以简单而有效的方式访问所有活跃的敌人。我挑战你使用WaveManager以同样的方式来做,这将有一个所有活跃波浪的集合,以便稍后检查是否所有波浪都完成了它们的工作,以考虑游戏胜利。花些时间解决这个问题;你将在下面的截图中发现解决方案,从WavesManager开始:

图 8.15:完整的 WavesManager 脚本
你还需要WaveSpawner脚本:

图 8.16:支持 WavesManager 的修改后的 WaveSpawner 脚本
如你所见,WaveManager的创建方式与EnemyManager相同,只是一个带有WaveSpawner引用列表的单例,但WaveSpawner是不同的。我们在WaveSpawner的Start事件中执行列表的Add函数来注册波浪为活跃波浪,但Remove函数需要做更多的工作。
策略是在生成器完成工作后从活跃波浪列表中注销波浪。在这次修改之前,我们使用Invoke在一段时间后调用CancelInvoke函数来停止生成,但现在我们需要在结束时间之后做更多的事情。
而不是在指定的波浪结束时间后调用CancelInvoke,我们将调用一个名为EndSpawner的自定义函数,该函数将调用CancelInvoke来停止生成器,Invoke Repeating,同时还会调用从WavesManager列表中移除的函数,以确保在WaveSpawner完成其工作的时候正好调用移除列表的函数。
关于视觉脚本版本,我们可以在场景变量中添加两个 GameObject 类型的列表来保存现有波浪和敌人的引用,这样我们就可以跟踪它们。只需在变量类型选择器的搜索栏中搜索“GameObject 列表”,你就可以找到它。在这种情况下,由于WaveSpawner和敌人脚本的视觉脚本版本不是我们可以像 C#那样引用的类型,所以列表中只包含 GameObject。如果你同时创建了这些脚本的 C#和视觉脚本版本,你会看到你可以引用 C#版本,但我们将不会混合 C#和视觉脚本,因为这超出了本书的范围,所以忽略它们。无论如何,鉴于视觉脚本变量系统的工作方式,我们仍然可以使用获取变量节点在需要时访问变量内部——记住变量不在视觉脚本中,而是在变量节点中:

图 8.17:将列表添加到场景变量
然后,我们可以在WaveSpawner图表中添加以下内容:

图 8.18:向列表中添加元素
我们使用添加列表项节点将我们的 GameObject 添加到波浪变量中。我们在On Start事件节点之前将其作为第一件事来做。要移除这个波浪从活动波浪中,你需要进行以下更改:

图 8.19:从列表中移除元素
我们使用For Loop的Exit流程输出引脚从列表中移除这个生成器,这是在for循环完成迭代时执行的。
最后,关于敌人,你需要创建一个新的敌人脚本图表,其外观将类似:

图 8.20:敌人添加和从列表中移除自身
如你所见,我们只是在OnStart时添加敌人,在OnDisable时移除它。记住,由于我们之前提到的错误,首先尝试使用OnDestroy而不是OnDisable。你可以通过在场景变量GameObject 被选中时玩游戏来检查这些更改,并看到其值如何变化。还要记住,如果我们正在更改场景,需要使用有变量节点。
使用对象管理器,我们现在可以集中管理一组对象的信息,并且可以在这里添加各种对象组逻辑。我们创建了EnemiesManager、WavesManager和ScoreManager作为集中存储多个游戏系统信息的中心位置,例如场景中存在的敌人和波浪,以及分数。我们还看到了视觉脚本版本,将数据集中存储在场景变量对象中,这样所有的视觉脚本都可以读取这些数据。但除了为了更新 UI(我们将在下一章中这样做)而拥有这些信息之外,我们还可以使用这些信息来检测我们的游戏是否满足胜利和失败条件,创建一个游戏模式对象来检测。
创建游戏模式
我们已经创建了对象来模拟游戏中的许多游戏玩法方面,但游戏总需要结束,无论我们赢或输。一如既往,问题是将这种逻辑放在哪里,这引出了更多的问题。主要问题将是,我们是否总是以相同的方式赢或输游戏?我们是否会有一个特殊的关卡,其标准不同于“消灭所有波次”,例如有时间限制的生存?只有你知道这些问题的答案,但如果现在答案是“不”,并不意味着它不会在以后改变,因此建议我们准备代码以无缝适应变化。
说实话,准备代码以无缝适应变化几乎是不可能的;没有一种方法可以考虑到每个可能的情况,我们迟早都需要重写一些代码。我们将尝试使代码尽可能适应变化;总是这样做不会消耗太多开发时间,有时快速编写简单的代码比慢速编写可能不必要的复杂代码更可取,因此我们建议您明智地平衡时间预算。
为了做到这一点,我们将胜利和失败条件的逻辑分离到自己的对象中,我喜欢将其称为“游戏模式”(不一定是行业标准)。这将是一个将监督游戏、检查需要满足的条件以考虑游戏结束的组件。它将像我们游戏中的裁判。游戏模式将不断检查对象管理器中的信息以及可能的其他信息源,以检测所需条件。将此对象与其他对象分离,使我们能够创建具有不同游戏模式的关卡;只需在该关卡中使用另一个游戏模式脚本即可。
在我们这个例子中,我们现在将只有一个游戏模式,它将检查波次和敌人的数量是否变为0,这意味着我们已经杀死了所有可能的敌人,游戏胜利。同时,它还将检查玩家的生命值是否达到0,在这种情况下,将游戏视为失败。让我们通过以下步骤创建它:
-
创建一个空的
GameMode对象,并向其中添加一个WavesGameMode脚本。正如你所见,我们给脚本起了一个描述性的名字,考虑到我们可以添加其他游戏模式。 -
在其
Update函数中,使用Enemy和Wave管理器检查敌人波次数量是否达到0;在这种情况下,目前只需在控制台print一条消息。所有列表都有一个Count属性,它将告诉你存储在内部元素的数量。 -
添加一个名为
PlayerLife的public字段,其类型为Life,并将玩家拖到该字段;这里的想法是也要检测失败条件。 -
在
Update中,添加另一个检查以检测playerLife引用的生命值是否达到0,在这种情况下,在控制台print一条失败消息:

图 8.21:WavesGameMode 中的胜利和失败条件检查
- 玩游戏并测试两种情况,即玩家生命值是否达到 0,或者你是否已经杀死了所有敌人以及所有波次。
现在,是时候用更有趣的消息替换它们了。目前,我们只是将当前场景更改为胜利场景或失败场景,这两个场景将只包含带有胜利或失败消息以及一个重新开始按钮的 UI。将来,你可以添加一个主菜单场景,并有一个选项返回到它。让我们通过以下步骤来实现:
-
创建一个新的场景(文件 | 新场景)并保存它,命名为
WinScreen。 -
添加一些指示这是胜利场景的东西,比如简单地用一个摄像机指向的球体。这样我们就可以知道何时切换到胜利场景。
-
在项目视图中选择场景,然后按Ctrl + D(在 Mac 上为Cmd + D)来复制场景。将其重命名为
LoseScreen。 -
双击
LoseScreen场景以打开它,并将球体更改为其他东西,比如一个立方体。 -
前往文件 | 构建设置以打开此窗口内的构建中的场景列表。
理念是 Unity 需要你明确声明所有必须包含在游戏中的场景。你可能有一些测试场景或你不想发布的场景,这就是为什么我们需要这样做。在我们的案例中,我们的游戏将包含WinScreen、LoseScreen以及我们迄今为止创建的带有游戏场景的场景,我将其称为Game,所以只需将这些场景从项目视图拖到构建设置窗口的列表中;我们需要这样做才能使游戏模式脚本在场景之间正确切换。此外,考虑到列表中的第一个场景将是我们在最终版本(称为构建)中玩游戏时首先打开的场景,所以你可能想要根据这一点重新排列列表:

图 8.22:将场景注册到游戏的构建中
-
在
WavesGameMode中添加对UnityEngine.SceneManagement命名空间的using语句,以启用此脚本中的场景更改功能。 -
将控制台
print消息替换为调用SceneManager.LoadScene函数的调用,该函数将接收一个包含要加载的场景名称的字符串;在这种情况下,将是WinScreen和LoseScreen。你只需要场景名称,而不需要文件的完整路径。
如果你想链接不同的层级,你可以创建一个public字符串字段,以便你通过编辑器指定要加载的场景。记住将场景添加到构建设置中,否则,当你尝试更改场景时,控制台将显示错误信息:

图 8.23:使用 SceneManager 更改场景
- 玩游戏并检查场景是否正确更改。
目前,我们选择了最简单的方式来显示我们输赢的状态,但将来,你可能想要比场景突然变化更柔和的方式,比如使用Invoke等待几秒钟来延迟变化,或者直接在游戏中显示获胜信息而不改变场景。在测试游戏并检查玩家在玩游戏时是否理解发生了什么时,请记住这一点——游戏反馈对于让玩家了解正在发生的事情非常重要,并且这不是一个容易解决的问题。
关于视觉脚本版本,我们在一个分离的对象中添加了一个新的脚本图。让我们逐个检查它,以便更清楚地了解它。让我们从胜利条件开始:

图 8.24:在视觉脚本中的胜利条件
在这里,我们从场景上下文中获取敌人列表(获取变量节点),并且知道它包含一个列表,所以我们使用计数项节点来检查这个列表中剩余多少敌人。记住,我们有一个脚本在敌人被生成时将其添加到列表中,在它被销毁时将其移除。我们对波次也做同样的处理,所以将条件与与节点组合,并通过如果节点连接,然后执行某些操作(更多内容将在稍后讨论)。
现在我们来检查失败条件:

图 8.25:视觉脚本中的失败条件
由于玩家的生命值不在场景上下文中(也不应该),而且玩家是一个与名为GameMode的对象不同的 GameObject(我们专门为这个脚本创建的),我们需要一个类型为 GameObject 的变量player来引用它。
如您所见,我们在变量组件中将我们的玩家拖拽到它上面。最后,我们使用获取变量来访问图中的玩家引用,然后使用另一个获取变量来提取生命值。我们通过将玩家引用连接到生命变量的获取变量节点来实现这一点。然后我们对玩家的基础重复这一过程。
最后,我们通过以下步骤加载场景:

图 8.26:在视觉脚本中加载场景
如您所见,我们使用场景管理器加载场景(场景名称)节点来加载场景。注意我们如何使用后缀_VisualScripting来加载场景,因为我们 GitHub 上有两个版本的场景,一个是 C#版本,另一个是视觉脚本版本。
现在我们有一个功能齐全的简单游戏,具有机制和胜负条件,虽然这足以开始开发我们游戏的其它方面,但我想要讨论一下我们当前管理方法中的一些问题以及如何通过事件来解决它们。
使用事件改进我们的代码
到目前为止,我们使用了 Unity 事件函数来检测游戏中可能发生的情况,例如Awake和Update。Unity 还使用其他类似函数来允许组件之间相互通信,例如OnTriggerEnter,这是 Rigidbody 通知 GameObject 中的其他组件发生了碰撞的一种方式。在我们的情况下,我们使用Update方法中的if语句来检测其他组件的变化,例如GameMode检查敌人数量是否达到 0。但如果我们从敌人管理器那里得到通知,当某些事情发生变化时,我们可以在那一刻进行检查,例如,用 Rigidbody 告诉我们何时发生碰撞,而不是每帧检查碰撞。
此外,有时我们依赖于 Unity 事件来执行逻辑,例如在OnDestroy事件中给出分数,该事件通知我们对象何时被销毁。但由于事件的本性,它可能在我们不希望添加分数的情况下被调用,例如当场景改变或游戏关闭时。在这种情况下,对象会被销毁,但这并不是因为玩家杀死了敌人,导致在不应该增加分数的情况下分数增加。在这种情况下,有一个事件告诉我们生命达到 0 以执行此逻辑,而不是依赖于通用的OnDestroy事件会很好。
事件的想法是改进我们对象之间的通信模型,确保在某个事件发生的确切时刻,相关部分被通知并做出相应的反应。Unity 有很多事件,但我们可以创建特定于我们游戏逻辑的事件。让我们先应用我们之前讨论的得分场景;想法是让Life组件有一个事件来通知其他组件对象被销毁是因为生命达到 0。
实现这一点有几种方法,我们将使用与Awake和Update方法略有不同的方法;我们将使用UnityEvent字段类型。这是一种字段类型,可以存储在需要执行时执行的函数引用,就像 C#委托一样,但具有其他优点,例如更好的 Unity 编辑器集成。
要实现这一点,请执行以下操作:
- 在
Life组件中,创建一个名为onDeath的UnityEvent类型的public字段。这个字段将代表一个事件,其他类可以订阅它,以便在Life达到 0 时得到通知:

图 8.27:创建自定义事件字段
- 如果你保存脚本并进入编辑器,你可以在检查器中看到事件。Unity 事件支持在编辑器中订阅方法,这样我们就可以将两个对象连接起来。我们将在 UI 脚本章节中使用这个功能,所以现在就忽略它:

图 8.28:UnityEvents 在检查器中显示
你可以使用通用委托操作或自定义委托来创建事件,而不是使用UnityEvent,除了某些性能方面外,唯一明显的区别是UnityEvent将在编辑器中显示,如步骤 2所示。
- 当生命值达到
0时,调用事件的Invoke函数。这样,我们将告诉任何对事件感兴趣的脚本它已经发生:

图 8.29:执行事件
-
在
ScoreOnDeath中,将OnDestroy函数重命名为GivePoints或你喜欢的任何名称;这里的想法是在OnDestroy事件中停止给予分数。 -
在
ScoreOnDeath脚本的Awake函数中,使用GetComponent获取Life组件并将其保存在局部变量中。 -
调用
Life引用的onDeath字段的AddListener函数,并将GivePoints函数作为第一个参数传递。这被称为订阅我们的listener方法GivePoints到事件onDeath。想法是告诉Life在onDeath事件被调用时执行GivePoints。这样,Life就会通知我们这种情况。记住,你不需要调用GivePoints,只需将其作为字段传递即可:

图 8.30:在那种情况下订阅 OnDeath 事件以给予分数
考虑在OnDestroy中调用RemoveListener;像往常一样,在可能的情况下取消订阅监听器以防止任何内存泄漏(引用阻止 GC 释放内存)是方便的。在这种情况下,这并不是完全必要的,因为Life和ScoreOnDeath组件将同时被销毁,但尽量养成这种良好的习惯。
- 保存,在编辑器中选择
ScoreManager,然后点击播放来测试这个功能。尝试在播放模式下从层次结构中删除一个敌人,以检查分数是否不会上升,因为敌人被销毁的原因不是他们的生命值变为 0;你必须射击敌人以看到分数增加。
现在,由于Life有了onDeath事件,我们也可以通过以下步骤将玩家的Life检查从WavesGameMode替换为使用该事件:
-
在
WavesGameMode脚本上创建一个OnPlayerDied函数,并将LoseScreen场景的加载从Update方法移动到这个函数。你将移除检查生命的if语句,因为事件版本将替换它。 -
在
Awake中,将这个新函数添加到玩家Life组件引用的onDeath事件中,在我们的脚本中称为playerLife:

图 8.31:使用事件检查失败条件
如您所见,创建自定义事件允许您检测比 Unity 中的默认值更具体的情况,并保持您的代码整洁,无需在Update函数中不断询问条件,这并不一定是坏事,但事件方法生成更清晰的代码。
记住,我们也可以通过玩家的基础Life达到 0 来输掉游戏。因此,让我们创建一个代表敌人将攻击以减少基地Life的对象的立方体。考虑到这一点,我挑战你将第二个失败条件(玩家的基础生命达到 0)添加到我们的脚本中。当你完成时,你可以在以下截图中检查解决方案:

图 8.32:完整的 WavesGameMode 失败条件
如您所见,我们只是重复了life事件订阅,请记住创建一个对象来表示玩家的基础伤害点,向其添加一个Life脚本,并将其拖动作为 Waves 游戏模式的玩家基础Life引用。这里有趣的是,我们订阅了名为OnPlayerOrBaseDied的相同函数,用于玩家的Life和基地Life的onDeath事件,因为我们希望在这两种情况下得到相同的结果。
现在,让我们通过将其应用于管理器来继续说明这个概念,以防止游戏模式每帧都检查条件:
-
在
EnemyManager中添加一个名为onChanged的UnityEvent字段。这个事件将在敌人被添加或从列表中移除时执行。 -
创建两个函数,
AddEnemy和RemoveEnemy,都接收一个Enemy类型的参数。想法是,而不是让Enemy直接从列表中添加和删除自己,它应该使用这些函数。 -
在这两个函数内部,调用
onChanged事件来通知其他人敌人列表已被更新。想法是,任何想要从列表中添加或删除敌人的人都需要使用这些函数:

图 8.33:在添加或删除敌人时调用事件
这里,我们没有阻止我们绕过这两个函数并直接使用列表的问题。你可以通过将列表设为私有并使用IReadOnlyList接口公开它来解决。记住,这样,列表在编辑器中不可见,以便进行调试。
- 将
Enemy脚本更改为使用这些函数:

图 8.34:使敌人使用添加和删除函数
- 对
WaveManager和WaveSpawner重复相同的过程,创建一个onChanged事件,创建AddWave和RemoveWave函数,并在WaveSpawner中调用它们而不是直接访问列表。这样,我们就确保在需要时调用事件,就像我们对EnemyManager所做的那样。尝试自己解决这个问题,然后检查以下截图中的解决方案,从WavesManager开始:

图 8.35:WaveManager OnChanged 事件实现
- 此外,
WavesSpawner也需要以下更改:

图 8.36:实现 AddWave 和 RemoveWave 函数
- 在
WavesGameMode中,将Update重命名为CheckWinCondition,并将其订阅到EnemyManager和WavesManager的onChanged事件。想法是只在必要时检查敌人和波次的数量变化。记住,由于单例在Awake中初始化,所以在Start函数中订阅事件。

图 8.37:当敌人或波次数量变化时检查胜利条件
关于视觉脚本版本,让我们从事件开始检查失败条件,首先检查生命脚本图表中需要的一些更改:

图 8.38:在我们的生命图表中触发自定义事件
首先,当生命值达到 0 时销毁物体后,我们使用触发自定义事件节点,指定事件名称为OnDeath。这将告诉所有等待执行OnDeath事件的人我们已经执行了。记住,这是我们生命脚本图表。在触发事件后务必调用销毁操作——虽然大多数时候顺序并不重要,因为销毁动作实际上是在帧尾发生的,但有时可能会引起问题,所以在这里还是安全为好。在这种情况下,游戏模式应该监听玩家的OnDeath事件,所以让我们在我们的游戏模式图表中做出以下更改:

图 8.39:在视觉脚本中监听玩家的 OnDeath 事件
我们使用了自定义事件节点,将其连接到我们的游戏模式的玩家引用。这样我们指定,如果那个玩家执行该事件,我们将执行加载场景节点。记住,玩家引用是至关重要的,因为它指定了我们想从谁那里执行OnDeath事件,并且记住生命视觉图表也会出现在敌人中,而我们对此不感兴趣。此外,记得移除我们之前用来检测此情况的If节点和条件节点——我们的游戏模式将只有一个If,那就是胜利条件。
实际上,我们让任何带有Life脚本的物体都有一个OnDeath事件,并且让游戏模式特别监听玩家的OnDeath事件。
我们也可以为敌人和波次做事件,但这会在一定程度上使我们的图表变得复杂,因为我们视觉脚本版本中没有WaveManager或EnemyManager。我们当然可以创建这些来完成任务,但有时使用视觉脚本的目的就是创建简单的逻辑,这类改变往往会使得图表变得相当庞大。
另一个可能的解决方案是让敌人波直接通知游戏模式。我们可以在敌人和波中使用触发自定义事件,将此节点连接到游戏模式,最终让游戏模式拥有一个自定义事件节点来监听。问题是这会违反我们对象之间的正确依赖关系;低级对象,如敌人和波,不应该与高级对象,如游戏模式,进行通信。本质上,游戏模式原本应该是一个监管者。如果我们应用本段中描述的解决方案,我们将无法在没有游戏模式的情况下在另一个场景或游戏中拥有敌人。因此,为了简单和代码解耦,让我们保持其他条件不变——更复杂的逻辑,如这个,在完整的生产项目中可能会用 C#来处理。
是的,使用事件意味着我们比以前需要编写更多的代码,从功能的角度来看,我们没有获得任何新的东西,但在更大的项目中,通过Update检查来管理条件,正如之前讨论的那样,会导致各种问题,如竞争条件和性能问题。有时,拥有可扩展的代码库需要更多的代码,这就是其中之一。
在我们结束之前,需要考虑的是,Unity 事件不是在 Unity 中创建此类事件通信的唯一方式;你将找到一个类似的方法,称为Action,这是事件的本地 C#版本,如果你想要查看所有选项,我建议你了解一下。
摘要
在本章中,我们完成了游戏的一个重要部分:无论是胜利还是失败,我们都讨论了通过使用通过单例创建的管理器来分离不同责任层的一种简单但强大的方法,以确保每种类型的经理只有一个实例,并通过静态访问简化它们之间的连接。此外,我们还探讨了事件的概念,以简化对象之间的通信,防止问题并创建更有意义的对象间通信。
带着这些知识,你现在不仅能够检测游戏的胜利和失败条件,而且还能以更好的结构化方式来做。这些模式可以用来改进我们的游戏代码,我建议你在其他相关场景中尝试应用它们。
在下一章中,我们将开始本书的第三部分,我们将看到不同的 Unity 系统来改进游戏的图形和音频方面,首先我们将看到如何创建材料来修改对象的一些属性,并使用 Shader Graph 创建着色器。
加入我们的 Discord 频道!
与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。
扫描二维码或访问链接加入社区。

packt.link/handsonunity22
第九章:实现为建筑敌人而设计的游戏人工智能
游戏如果不是对玩家的一项巨大挑战,那它又是什么呢?玩家需要使用他们的角色能力来应对不同的场景。每个游戏都会对玩家施加不同类型的障碍,而我们游戏中的主要障碍是敌人。创建具有挑战性和可信度的敌人可能很复杂;它们需要表现得像真实人物,并且足够聪明,以便不会被轻易杀死,但同时也足够简单,以至于它们不是不可能被杀死的。我们将使用基本但足够的 AI 技术来制作一个能够感知其周围环境的人工智能,并根据这些信息做出决策,决定做什么,使用FSMs(有限状态机)或其他技术。这些决策将通过智能路径查找来执行。
在本章中,我们将探讨以下人工智能概念:
-
使用传感器收集信息
-
使用 FSMs 做出决策
-
执行 FSM 动作
到本章结束时,你将拥有一个能够检测玩家并攻击他们的完全功能性的敌人,所以让我们首先看看如何制作传感器系统。
使用传感器收集信息
人工智能通过首先获取其周围环境的信息来工作。然后,这些数据被分析以选择一个动作,最后执行所选动作。正如你所看到的,没有信息我们无法做任何事情,所以让我们从这部分开始。
我们的人工智能可以使用多个信息来源,例如关于自身的数据(生命和子弹)或可能是某些游戏状态(胜利条件或剩余敌人),这些都可以通过我们迄今为止看到的代码轻松找到。然而,一个重要的信息来源也是人工智能的感知。根据我们游戏的需要,我们可能需要不同的感知,如视觉和听觉,但在这个案例中,视觉就足够了,所以让我们学习如何编写这部分代码。
在本节中,我们将探讨以下传感器概念:
-
使用 C#创建三过滤传感器
-
使用视觉脚本创建三过滤传感器
-
使用小工具进行调试
让我们先看看如何使用三过滤法创建一个传感器。
使用 C#创建三过滤传感器
编写感知代码的常见方式是通过三过滤法来排除视线之外的敌人。第一个过滤器是距离过滤器,它会排除太远而看不见的敌人,然后第二个过滤器将是角度检查,它会检查我们视野锥体内的敌人,最后,第三个过滤器是射线投射检查,它会排除被墙壁等障碍物遮挡的敌人。
在开始之前,有一句话要提醒:在这里我们将使用矢量数学,而深入探讨这些主题超出了本书的范围。如果你不理解某些内容,请随时在网上搜索截图中的代码。
让我们以下面的方式编写传感器代码:
- 创建一个空的
GameObject,命名为AI,作为敌人预设的子对象。您需要首先打开预设以修改其子对象(双击预设)。请记住将此GameObject的变换设置为位置 0,1.75,0,旋转 0,0,0,和缩放 1,1,1,以便它与敌人的眼睛对齐。这样做是为了未来我们将做的视线传感器。请考虑您的敌人预设的眼睛可能具有不同的高度。虽然我们当然可以直接将所有 AI 脚本直接放入敌人预设根GameObject中,但我们这样做只是为了分离和组织:

图 9.1:AI 脚本容器
-
创建一个名为
Sight的脚本并将其添加到AI子对象中。 -
创建两个名为
distance和angle的float类型字段,以及另外两个名为obstaclesLayers和objectsLayers的LayerMask类型字段。distance将用作视野距离,angle将确定视锥体的幅度,obstacleLayers将用于我们的障碍物检查以确定哪些对象被认为是障碍物,而objectsLayers将用于确定我们想要Sight组件检测的对象类型。我们只想让视线看到敌人;我们对墙壁或增益物品等对象不感兴趣。
LayerMask是一种属性类型,允许我们在代码中选择一个或多个层来使用,因此我们将通过层过滤对象。稍后您将看到我们如何使用它:![图片]()
图 9.2:用于参数化我们的视线检查的字段
-
在
Update中,如图 9.3所示调用Physics.OverlapSphere。
此函数在由第一个参数指定的地方(在我们的情况下,是我们的位置)创建一个想象中的球体,半径由第二个参数(距离属性)指定,以检测具有第三个参数指定的层(ObjectsLayers)的对象。它将返回一个包含在球体内所有找到的碰撞器的数组;这些函数使用物理来执行检查,因此对象必须至少有一个碰撞器。
这是我们将用于找到我们视野距离内的所有敌人的方法,我们将在下一步进一步过滤它们。请注意,我们将位置传递给第一个参数,这实际上不是敌人的位置,而是AI子对象的位置,因为我们的脚本位于那里。这突出了 AI 对象位置的重要性。
完成第一次检查的另一种方法是检查我们想要看到的对象与玩家之间的距离,或者如果我们正在寻找其他类型的对象,则检查包含这些对象列表的Manager组件。然而,我们选择的方法更加灵活,可以用于任何类型的对象。
此外,您可能还想检查Physics.OverlapSphereNonAlloc此函数的版本,它执行相同的操作,但通过不分配返回结果的数组而更高效。
- 使用
for循环遍历函数返回的对象数组:

图 9.3:获取一定距离内的所有 GameObject
-
为了检测物体是否落在视野锥内,我们需要计算我们的观察方向与从我们自身指向物体本身的方位之间的角度。如果这两个方向之间的角度小于我们的锥形角度,我们认为物体在我们的视野内。我们将在以下步骤中这样做:
开始计算指向物体的方向,这可以通过将物体位置与我们的位置之间的差异进行归一化来实现,就像在图 9.4中所示。你可能注意到我们使用了
bounds.center而不是transform.position;这样,我们检查的是指向物体中心的方位,而不是它的旋转点。记住,玩家的旋转点在地面,射线检查可能会在玩家之前与它碰撞:![]()
图 9.4:从我们的位置计算指向碰撞体的方向
-
我们可以使用
Vector3.Angle函数来计算两个方向之间的角度。在我们的情况下,我们可以计算指向敌人的方向和我们的前进向量之间的角度来查看角度:

图 9.5:计算两个方向之间的角度
如果你愿意,你可以使用Vector3.Dot,这将执行点积,这是一种数学函数,用于计算向量投影到另一个向量上的长度(在网上搜索更多信息)。Vector3.Angle实际上使用的是这个函数,但它将点积的结果转换为角度,这需要使用三角函数,并且可能计算起来很耗时。但我们的Vector3.Angle方法更简单,编码更快,而且鉴于我们不需要很多传感器,因为我们不会有太多敌人,使用点积优化传感器现在不是必要的,但考虑一下对于更大规模的游戏。
-
现在检查计算出的角度是否小于在
angle字段中指定的角度。注意,如果我们设置角度为90,实际上将是180,因为如果Vector3.Angle函数返回,例如,30,它可能是向左或向右的30。如果我们的角度是90,它可能是向左和向右的90,所以它将检测到 180 度弧内的物体。 -
使用
Physics.Linecast函数在第一个和第二个参数(我们的位置和碰撞体位置)之间创建一个假想线,以检测在第三个参数(障碍物层)中指定的层上的对象,并返回一个boolean值,指示该射线是否击中了某个物体。理念是使用线来检测我们和检测到的碰撞体之间是否存在任何障碍物,如果没有障碍物,这意味着我们有一个直接视线通向该对象。观察我们如何在图 9.6中使用
!或not运算符来检查Physics.Linecast是否检测到任何对象。再次注意,这个函数依赖于具有碰撞体的障碍物对象,在我们的例子中,我们有(墙壁、地板等):![]()
图 9.6:使用 Linecast 检查传感器和目标对象之间的障碍物
-
如果对象通过了三个检查,这意味着这是我们当前看到的对象,因此我们可以将其保存到名为
detectedObject的Collider类型字段中,以便其他AI脚本稍后使用该信息。考虑使用
break来停止迭代碰撞体的for循环,以防止通过检查其他对象而浪费资源,并在for之前将detectedObject设置为null以清除前一帧的结果。所以如果在这一帧中没有检测到任何东西,它将保持null值,这样我们就能注意到传感器中没有东西:

图 9.7:完整的传感器脚本
在我们的例子中,我们只是使用传感器来寻找玩家,这是传感器负责寻找的唯一对象,但如果你想让传感器更高级,你只需保留一个检测到的对象列表,将每个通过三个测试的对象放入其中,而不是只放入第一个。在我们的例子中,这并不必要,因为我们游戏中只有一个玩家。
- 在编辑器中,根据您的意愿配置传感器。在这种情况下,我们将
ObjectsLayer设置为Player,以便我们的传感器将搜索焦点放在具有该层的对象上,并将obstaclesLayer设置为Default,这是我们用于墙壁和地板的层。记住,Sight脚本位于AIGameObject 中,它是Enemy预制件的子对象:

图 9.8:传感器设置
- 要测试这一点,只需在玩家前方放置一个移动速度为 0 的敌人,选择其
AI子对象,然后玩游戏以查看属性在检查器中的设置情况。也可以尝试在两者之间放置一个障碍物,并检查属性是否显示None(null)。如果您没有得到预期的结果,请仔细检查您的脚本、其配置以及玩家是否具有Player层,障碍物是否具有Default层。此外,您可能需要将AI对象稍微抬高一点,以防止射线从地面开始并击中它。
考虑到脚本的大小,我们为视觉脚本版本分配一个整个章节,因为它还引入了一些在这里需要的新视觉脚本概念。
使用视觉脚本创建三过滤传感器
关于视觉脚本版本,让我们逐部分检查,从重叠球体开始:

图 9.9:视觉脚本中的重叠球体
到目前为止,我们只是在将sensedObject变量设置为null后调用了重叠球体。需要考虑的是,检查器中变量组件中的sensedObject变量没有类型(在视觉脚本中,Null类型实际上没有类型)。这在 C#中是不可能的——所有变量都必须有类型——虽然我们可以将sensedObject变量设置为正确的类型(Collider),但我们将会通过脚本稍后设置变量类型。即使我们现在设置类型,如果未设置值,视觉脚本往往会忘记类型,并且我们无法设置它,直到我们检测到某个东西。
目前不必担心这个问题;当我们通过脚本设置变量时,它将获得正确的类型。实际上,在视觉脚本中,所有变量都可以在运行时根据我们设置的值切换类型,这取决于变量组件的工作方式。不过,我不建议这样做:尽量坚持使用预期的变量类型。
我们刚才说过,C#中的所有变量都必须有类型,但这并不完全正确。有方法可以创建动态类型的变量,但这不是一种我推荐使用的良好实践,除非没有其他选择。
另一个需要注意的事情是我们如何使用Null节点在开始时将sensedObject变量设置为null,这实际上代表了null值。
现在,让我们探索Foreach部分:

图 9.10:视觉脚本中的集合迭代
我们可以看到重叠球体的一个输出引脚是一个小列表,它本质上代表了由重叠球体返回的碰撞器数组。我们将该引脚连接到For Each Loop节点,正如你可能想象的那样,它遍历提供的集合(数组、列表、字典等)。Body引脚代表循环中要执行的节点,Item输出引脚代表当前正在迭代的项——在我们的案例中,是重叠球体检测到的碰撞器之一。最后,我们将该项保存在一个Flow potentialDetection变量中,Flow变量在 C#函数中相当于局部变量。
这里的想法是,考虑到图的大小和我们查询当前迭代项的次数,我们不想让连接输出Item引脚到其他节点的线穿过整个图。相反,我们将该项保存在Flow变量中以供以后引用,本质上是在图中命名该值以供以后引用,你将在下一部分中看到这一点。
现在我们来探索角度检查:

图 9.11:视觉脚本中的角度检查
在这里,你可以看到我们将 C#中检测角度的直接翻译,所以应该很容易理解。这里唯一需要注意的是,由于Item输出引脚与Get Position节点(我们查询其位置的节点)的邻近性,我们直接连接了该节点,但稍后我们将使用potentialDetection流变量。
现在,让我们探索Linecast部分:

图 9.12:在视觉脚本中的 Linecast 检查
再次强调,这与我们在 C#中之前所做的是基本相同的。这里要强调的唯一一点是我们使用了Flow变量potentialDetection来再次获取当前迭代的项的位置,而不是将Get Position节点一直连接到Foreach Item输出引脚。
现在,让我们探索最后一部分:

图 9.13:设置 sensedObject
再次,相当直观;如果Linecast返回false,我们将potentialDetection变量(当前迭代的项)设置为sensedObject变量(稍后其他脚本将访问它以查询我们的 AI 现在可以看到哪个对象)。这里需要考虑的是Break Loop节点的使用,它是 C#中break关键字的等效物;本质上,我们正在停止我们当前所在的Foreach循环。
现在,即使我们的传感器正在工作,有时检查它是否正常工作或正确配置也需要一些我们可以使用 Gizmos 创建的视觉辅助工具。
使用 Gizmos 进行调试
当我们创建我们的 AI 时,我们将开始检测边缘情况中的某些错误,通常与配置错误有关。你可能认为玩家在敌人的视距范围内,但你可能看不到视线被一个物体遮挡,尤其是在敌人不断移动的情况下。调试这些场景的一个好方法是使用仅编辑器可见的视觉辅助工具Gizmos,它允许你可视化不可见的数据,如视距或用于检测障碍物的Linecasts。
让我们通过以下步骤来了解如何通过绘制表示视距的球体来创建Gizmos:
-
在
Sight脚本中,创建一个名为OnDrawGizmos的事件函数。这个事件仅在编辑器中执行(不在构建中),并且是 Unity 要求我们绘制Gizmos的地方。 -
使用
Gizmos.DrawWireSphere函数,将我们的位置作为第一个参数,将距离作为第二个参数来绘制一个球体,其半径等于我们的距离。你可以检查当你改变距离字段时Gizmo的大小如何变化:

图 9.14:球体 Gizmo
- 可选地,你可以在调用绘图函数之前更改 gizmo 的颜色,设置
Gizmos.color:

图 9.15:Gizmos 绘制代码
现在你正在不断地绘制Gizmos,如果你有很多敌人,它们可能会因为过多的Gizmos而污染场景视图。在这种情况下,尝试使用OnDrawGizmosSelected事件函数,它只在对象被选中时绘制Gizmos。
- 我们可以使用
Gizmos.DrawRay来绘制代表锥形的线条,它接收要绘制的线条的起点和方向,可以通过乘以一个特定的值来指定线条的长度,如下面的截图所示:

图 9.16:绘制旋转的线条
- 在截图中,我们使用了
Quaternion.Euler根据我们想要旋转的角度生成四元数。四元数是一种数学结构,用于表示旋转;请搜索此术语以获取更多关于它的信息。如果我们用这个四元数乘以一个方向,我们将得到旋转后的方向。我们正在根据角度字段旋转我们的前向向量,以生成我们的锥形视野线。
此外,我们将这个方向乘以视线距离来绘制线条,直到我们的视线所能看到的地方;你会看到线条如何与球体的末端匹配:

图 9.17:视野角度线
我们还可以绘制Linecasts,它们检查障碍物,但鉴于这些取决于游戏的当前情况,例如通过前两个检查的对象及其位置,我们可以使用Debug.DrawLine代替,它可以在Update方法中执行。这个版本的DrawLine是为运行时设计的。我们看到的Gizmos也在编辑器中执行。让我们以下面的方式尝试它们:
- 首先,让我们调试
Linecast没有检测到任何障碍物的情况,因此我们需要在传感器和物体之间绘制一条线。我们可以在调用Linecast的if语句中调用Debug.DrawLine,如下面的截图所示:

图 9.18:在 Update 中绘制线条
- 在下一个截图中,你可以看到
DrawLine的实际应用:

图 9.19:指向检测到的物体的线条
- 当视线被物体遮挡时,我们也想用红色绘制一条线。在这种情况下,我们需要知道
Linecast碰撞的位置,因此我们可以使用函数的重载,它提供了一个out参数,可以给我们更多关于线条碰撞的信息,例如碰撞位置、法线和碰撞物体,如下面的截图所示:

图 9.20:获取 Linecast 的信息
注意,Linecast并不总是与最近的障碍物碰撞,而是与它在直线上检测到的第一个对象碰撞,这可能会变化。如果你需要检测最近的障碍物,请查找Physics.Raycast函数的版本。
- 我们可以利用这些信息在
else分支中,当线条与物体碰撞时,从我们的位置绘制到碰撞点:

图 9.21:如果有障碍物则绘制线条
- 在下一张屏幕截图中,你可以看到结果:

图 9.22:当障碍物遮挡视线时的线条
关于视觉脚本版本,第一部分将看起来像这样:

图 9.23:在视觉脚本中绘制 Gizmos
然后,角度线将看起来像这样:

图 9.24:在视觉脚本中绘制视线角度线
注意,这里我们只展示了一个,但另一个本质上相同,只是将角度乘以-1。最后,指向检测到的对象和障碍物的红色线条将看起来像这样:

图 9.25:在视觉脚本中绘制指向障碍物或检测到的对象的线条
注意,为了完成最后一点,我们需要将之前的Linecast节点更改为返回Raycast Hit信息的版本。
在所有这些的基础上,在本节中,我们创建了传感器系统,它将为我们的 AI 提供视力,并大量关于下一步要做什么的信息。现在我们的传感器已经完成,让我们利用它们提供的信息使用有限状态机(FSMs)来做出决策。
使用 FSMs 做出决策
我们在之前使用Animator组件时探讨了有限状态机(FSMs)的概念。我们了解到,FSM 是一组状态,每个状态代表一个对象在某一时刻可以执行的动作,以及一组转换,这些转换规定了状态如何切换。这个概念不仅用于动画,而且在无数的编程场景中都有应用,其中之一就是 AI。我们只需在状态中用 AI 代码替换动画,我们就有了一个 AI FSM。
在本节中,我们将探讨以下 AI FSM 概念:
-
在 C#中创建 FSM
-
创建转换
-
在视觉脚本中创建 FSM
让我们先创建我们的 FSM 框架。
在 C#中创建 FSM
要创建我们自己的 FSM,我们需要回顾一些基本概念。记住,FSM 可以为每个可能执行的动作有一个状态,并且一次只能执行一个。
在 AI 方面,例如,我们可以巡逻、攻击、逃跑等。还要记住,状态之间存在转换,这些转换决定了从一种状态转换到另一种状态所需满足的条件,在 AI 方面,这可能是用户靠近敌人开始攻击或生命值低开始逃跑。在下图中,你可以找到一个简单的门两种可能状态的提醒示例:

图 9.26:FSM 示例
-
有几种方法可以实现 AI 的 FSMs;如果你愿意,甚至可以使用
Animator组件,或者从资源商店下载一些 FSM 系统。在我们的情况下,我们将采取最简单的方法,即一个包含一系列If语句的单个脚本,这虽然简单,但仍然是理解概念的好开始。让我们通过以下步骤来实现它: -
在敌人的
AI子对象中创建一个名为EnemyFSM的脚本。 -
创建一个名为
EnemyState的enum,包含GoToBase、AttackBase、ChasePlayer和AttackPlayer值。我们将在我们的 AI 中拥有这些状态。 -
创建一个名为
currentState的EnemyState类型字段,它将保存我们敌人的当前状态:

图 9.27:EnemyFSM 状态定义
-
创建三个以我们定义的状态命名的函数。
-
根据当前状态在
Update中调用这些函数:

图 9.28:基于 If 的有限状态机
是的,您完全可以在这里使用 switch,但我只是更喜欢这个示例中的常规if语法。
- 在编辑器中测试如何更改
currentState字段将更改哪个状态是活动的,查看控制台中的打印消息:

图 9.29:状态测试
如您所见,这是一个相当简单但完全功能的方法。在未来,您可能会面临需要编写具有更多状态敌人的情况,这种方法将开始变得不适用。在这种情况下,您可以使用您喜欢的任何资产商店的有限状态机插件,以获得更强大和可扩展的工具,或者甚至考虑更高级的技术,如行为树,但这超出了本书的范围。现在让我们继续使用有限状态机,创建其转换。
创建转换
如果您还记得在Animator Controller中创建的转换,那些基本上是一组条件,如果转换所属的状态是活动的,则会检查这些条件。在我们的有限状态机方法中,这简单地转化为检测状态内条件的If语句。让我们按照以下方式创建我们建议状态之间的转换:
-
在我们的有限状态机脚本中添加一个名为
sightSensor的Sight类型字段,并将 AIGameObject拖动到该字段以连接到那里的Sight组件。由于有限状态机组件与Sight组件位于同一对象中,我们也可以使用GetComponent,但在高级 AI 中,你可能有不同的传感器来检测不同的对象,所以我更喜欢为那种情况准备我的脚本。你应该选择你最喜欢的方法。 -
在
GoToBase函数中,检查Sight组件检测到的对象是否不为null,这意味着在我们的视线中有东西。如果我们的 AI 正在向基地移动但检测到障碍物中的对象,我们必须切换到Chase状态以追击玩家,因此我们改变状态,如下面的截图所示:

图 9.30:创建转换
- 此外,如果我们离必须损坏以减少基础生命值的目标足够近,我们必须切换到
AttackBase。我们可以创建一个名为baseTransform的Transform类型字段,并将之前创建的玩家基础生命值对象拖动到那里,以便我们可以检查距离。记得添加一个名为baseAttackDistance的浮点字段,以便可以配置该距离:

图 9.31:GoToBase 转换
- 在
ChasePlayer的情况下,我们需要检查玩家是否在视线之外,以便切换回GoToBase状态,或者我们是否足够接近玩家以开始攻击它。我们需要另一个名为PlayerAttackDistance的距离字段,它决定了攻击玩家的距离,我们可能希望为这两个目标设置不同的攻击距离。考虑在转换中提前返回,以防止在没有任何传感器检测到的对象时尝试访问其位置时出现null引用异常:

图 9.32:ChasePlayer 状态转换
- 对于
AttackPlayer,我们需要检查玩家是否在视线之外以返回GoToBase,或者它是否足够远以返回追逐它。你会注意到我们是如何将playerAttackDistance相乘,使停止攻击的距离略大于开始攻击的距离;这将防止当玩家接近那个距离时在攻击和追逐之间快速切换。
你可以将其设置为可配置的,而不是硬编码1.1:

图 9.33:AttackPlayer 状态转换
-
在我们的案例中,
AttackBase将不会有任何转换。一旦敌人足够接近基地以攻击它,它就会保持这种状态,即使玩家开始射击它。一旦到达那里,它的唯一目标就是摧毁基地。 -
记住你可以使用
Gizmos来绘制距离:

图 9.34:FSM Gizmos
- 在点击播放之前选择 AI 对象并移动玩家,以测试脚本,检查检查器中的状态如何变化。你还可以保留每个状态中的原始
print消息,以在控制台中查看它们的变化。记住要设置攻击距离和对象的引用。在截图中,你可以看到我们使用的设置:

图 9.35:敌人 FSM 设置
我们现在会遇到的一个小问题是,生成的敌人将不会有进行到玩家基地转换的距离计算所需的引用。你会注意到,如果你尝试将场景中敌人的更改应用到 Prefab(覆盖 -> 应用全部),则基地转换变量将显示为None。记住,Prefab 不能包含对场景中对象的引用,这使我们的工作变得更加复杂。一个替代方案是创建BaseManager,一个单例,它持有对损坏位置的引用,这样我们的EnemyFSM就可以访问它。另一个方案可能是使用GameObject.Find等函数来查找我们的对象。
在这种情况下,我们将看到后者。尽管它可能比Manager版本性能略低,但我想要向你展示如何使用它来扩展你的 Unity 工具集。在这种情况下,只需在Awake中将baseTransform字段设置为GameObject.Find的返回值,使用BaseDamagePoint作为第一个参数,这将查找具有相同名称的对象,如下面的截图所示。
你会看到现在我们的波生成的敌人将改变状态:

图 9.36:通过名称在场景中搜索对象
现在我们已经将 FSM 状态编码并正确执行了转换,让我们看看如何在视觉脚本中做到同样的事情。如果你只对 C#版本感兴趣,可以自由跳过以下部分。
在视觉脚本中创建 FSM
到目前为止,在视觉脚本中的大多数脚本几乎与 C#版本相同,只是在一些节点上有所不同。在考虑状态机时,我们可以做同样的事情,但相反,我们将使用视觉脚本的状态机系统。概念是相同的,你有状态并且可以切换它们,但状态的组织方式和转换何时触发是通过视觉管理的,这与 Animator 系统的方式相似。所以,让我们通过创建我们的第一个状态机图和一些状态来看看我们如何使用这个系统。按照以下步骤操作:
-
将状态机组件添加到我们的敌人上。请记住,它被称为状态机而不是脚本机,后者是常规视觉脚本的组件。
-
在组件中点击新按钮,并选择一个位置保存
fixed资产,方式与我们迄今为止为常规视觉脚本所做的方式相似。在我的情况下,我将其命名为EnemyFSM。

图 9.37:创建视觉状态机
-
双击状态机图以通常方式编辑它。
-
在图编辑器的任何空白区域右键单击,然后选择创建脚本状态以创建一个新状态:

图 9.38:创建我们的第一个视觉状态机状态
- 重复步骤 4,直到你拥有 4 个状态:

图 9.39:视觉状态
- 选择任何一个,然后在左侧的信息面板中,将标题字段(第一个)填写为我们之前创建的任何状态的名字(
GoToBase、AttackBase、ChasePlayer和AttackPlayer)。如果你看不到信息面板,点击中间带有i的按钮以显示它:

图 9.40:重命名视觉状态
- 重复上述步骤,直到你为每个状态节点命名,每个节点都对应于本章“在 C#中创建 FSM”部分中创建的状态:

图 9.41:所有需要的状态
- 你可以看到其中一个状态顶部有一个绿色条,这代表哪个节点应该是第一个。我将那个初始状态重命名为
GoToBase,因为这是我更喜欢作为第一个的状态。如果你没有将其作为起始状态,右键单击当前具有绿色条的状态机中的节点,选择切换起始以从它那里移除绿色条,然后对你想作为第一个的节点(在我们的场景中是GoToBase)重复此操作,将绿色条添加到该节点上。
需要考虑的是,在视觉脚本中可以有一个以上的起始状态,这意味着您可以同时运行多个状态并转换。如果可能,我建议避免同时只有一个状态处于活动状态,以使事情变得简单。
- 双击
GoToBase以进入这些状态的编辑模式。将一个String节点连接到OnUpdate事件节点中的print Message输入引脚,以打印一条消息说GoToBase:

图 9.42:我们的第一个状态机逻辑
- 在顶部栏中,单击GoToBase左侧的EnemyFSM标签以返回到整个状态机视图。如果您看不到它,请单击第三个按钮(看起来像
)右侧的任何文本标签:

图 9.43:返回到状态机编辑模式
-
如果您不打算使用其他事件节点,可以随意删除它们。
-
对每个状态重复步骤 9-11,直到它们都打印出它们的名称。
通过这种方式,我们已经创建了表示我们 AI 可能状态的节点。在下一节中,我们将为它们添加逻辑,使其变得有意义,但在那之前,我们需要通过以下方式创建状态之间的转换和触发它们的条件:
-
在敌人的变量组件中创建变量
baseTransform、baseAttackDistance和playerAttackDistance,因为我们将在执行转换时需要它们。 -
不要为
baseTransform设置任何类型,因为我们将在稍后通过代码填充它,但关于baseAttackDistance,使用Float类型并设置值为2,最后对于playerAttackDistance,也使用Float并设置值为3。如果您愿意,可以更改这些值:

图 9.44:我们转换所需的变量
- 右键单击
GoToBase节点,选择创建转换选项,然后单击ChasePlayer节点。这将在这两个状态之间创建一个转换:

图 9.45:两个状态之间的转换
- 对 C#版本中创建的每个转换重复步骤 3。
状态机图形需要看起来像以下截图:

图 9.46:所有需要的转换
-
双击GoToBase和ChasePlayer之间的转换中间的黄色形状以进入转换模式。在这里,您将能够指定将触发该转换的条件(而不是在状态逻辑中使用
If节点)。请记住,您有两个黄色形状,每个方向一个,所以请确保您双击的是基于连接它们的白色箭头的正确形状。 -
修改图形以检查
sensedObject变量是否不是null。它应该看起来像这样:

图 9.47:添加转换条件
- GoToBase 和 AttackBase 之间的转换应该看起来像这样:

图 9.48:从 GoToBase 到 AttackBase 的转换条件
- 现在,ChasePlayer 到 GoToBase 应该如下所示:

图 9.49:从 ChasePlayer 到 GoToBase 的转换条件
- 对于从 ChasePlayer 到 AttackPlayer 的转换,按照 图 9.50 所示操作。这本质上与 GoToBase 和 AttackBase 相同,都是距离检查,但目标不同:

图 9.50:从 ChasePlayer 到 AttackPlayer 的转换条件
- 对于从 AttackPlayer 到 ChasePlayer 的转换,按照 图 9.51 所示操作。这又是一个距离检查,但现在检查距离是否更大,并将距离乘以
1.1(以防止像我们在 C# 版本中解释的那样转换抖动):

图 9.51:从 AttackPlayer 到 ChasePlayer 的转换条件
- 最后,对于从 AttackPlayer 到 GoToBase 的转换,这是预期的图形:

图 9.52:从 AttackPlayer 到 GoToBase 的转换条件
在继续之前,我们需要解决的一个小细节是我们仍然没有在 baseTransform 变量中设置任何值。想法是通过代码来填充它,就像我们在 C# 版本中所做的那样。但在这里需要考虑的是,我们无法将 Awake 事件节点添加到整个状态机中,而只能添加到状态中。
在这种情况下,我们可以使用 OnEnterState 事件,这是一个状态机的专用事件节点。它将在状态变为活动状态时立即执行,这对于状态初始化很有用。我们可以在 GoToBase 状态的 OnEnterState 事件节点中添加初始化 baseTransform 变量的逻辑,因为这是我们首先执行的状态。
这样,GoToBase 逻辑将看起来像 图 9.53 所示。记得双击状态节点来编辑它:

图 9.53:GoToBase 初始化逻辑
注意,在这里,我们仅在 Null Check 的 Null 插针上将 Find 节点的结果设置到变量中。Null Check 的作用是检查我们的 baseTransform 变量是否已设置,如果是,则通过 Not Null 插针,如果不是,则通过 Null 插针。这样我们就可以避免每次进入 GoToBase 状态时都执行 GameObject.Find,而只需第一次。此外,请注意,在这种情况下,我们不仅会在对象初始化时执行 Set Variable 节点,而且每次 GoToBase 成为当前状态时也会执行。如果这导致意外的行为,其他选项可以是创建一个新的初始状态,初始化所有内容,然后转换到其他状态,或者也许可以创建一个经典的 Visual Script 图形,在 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。就像光照贴图一样,NavMesh被烘焙到一个文件中供以后使用。就像光照贴图一样,这里的主要缺点是NavMesh对象在运行时不能改变。如果你销毁或移动一个地板砖,AI 仍然会走过那个区域。NavMesh本身没有注意到地板已经不在了,所以你无法以任何方式移动或修改这些对象。幸运的是,在我们的情况下,我们不会在运行时遭受场景的任何修改,但请注意,有一些组件,如NavMeshObstacle,可以帮助我们在那些场景中。
要为场景生成NavMesh,请执行以下操作:
-
选择任何可通行物体及其上面的障碍物,例如地板、墙壁和其他障碍物,并将它们标记为静态。你可能记得静态复选框也会影响光照贴图,所以如果你想使一个物体不参与光照贴图但要对
NavMesh生成做出贡献,你可以点击静态检查框左侧的箭头,并仅选择导航静态。尽量将Navigation StaticGameObjects 限制在敌人实际会穿越的物体上,以增加NavMesh生成速度。在我们的案例中,使地形可通行会增加生成时间很多,而且我们永远不会在那个区域玩游戏。 -
在窗口 | AI | 导航中打开
NavMesh面板。 -
选择烘焙选项卡,点击窗口底部的烘焙按钮,并检查生成的
NavMesh:

图 9.55:生成 NavMesh
这基本上就是你需要做的所有事情。当然,还有很多设置你可以调整,例如最大坡度,它表示 AI 能够爬升的最大坡度角度,或者步高,它将决定 AI 是否能够爬楼梯,连接NavMesh中楼梯之间的楼层,但鉴于我们的场景简单,默认设置就足够了。
现在,让我们让我们的 AI 在NavMesh周围移动。
使用路径查找
为了制作一个使用NavMesh移动的 AI 对象,Unity 提供了NavMeshAgent组件,这将使我们的 AI 粘附在NavMesh上,防止物体超出其范围。它不仅会自动计算到指定目的地的路径,还会使用模拟人类移动路径的方式,通过转向行为算法移动物体,在角落减速并使用插值转向,而不是瞬间转向。此外,此组件还能够避开场景中运行的其它NavMeshAgent GameObjects,防止所有敌人同时聚集在同一位置。
让我们通过以下步骤使用这个强大的组件:
- 选择敌人预设件,并将其
NavMeshAgent组件添加到其中。将其添加到根对象,即名为Enemy的对象,而不是 AI 子对象——我们希望整个对象都能移动。你将看到围绕对象的一个圆柱体,表示该对象在NavMesh中将占用的区域。请注意,这并不是一个碰撞器,因此它不会被用于物理碰撞:

图 9.56:NavMeshAgent 组件
-
移除
ForwardMovement组件;从现在起,我们将使用NavMeshAgent来驱动敌人的移动。 -
在
EnemyFSM脚本的Awake事件函数中,使用GetComponentInParent函数来缓存NavMeshAgent的引用。这将与GetComponent类似工作——它将在我们的GameObject中查找组件,但如果组件不存在,这个版本将尝试在所有父组件中查找该组件。请记住添加using UnityEngine.AI行以在脚本中使用NavMeshAgent类:

图 9.57:缓存父组件引用
如您所想象,也存在GetComponentInChildren方法,它首先在GameObject中搜索组件,如果需要,然后在所有子组件中搜索。
- 在
GoToBase状态函数中,调用NavMeshAgent引用的SetDestination函数,传入基地对象的当前位置作为目标:

图 9.58:为我们的 AI 设置目标
-
保存脚本,并在场景中的几个敌人或由波浪生成的敌人上测试此脚本。您将看到问题,即敌人永远不会停止向目标位置移动,如果需要,甚至当他们足够接近时,也会进入对象内部。这是因为我们从未告诉
NavMeshAgent停止,我们可以通过将代理的isStopped字段设置为true来实现这一点。您可能想要调整基础攻击距离,使敌人停止得更近或更远:
![]()
图 9.59:停止代理移动
-
我们可以对
ChasePlayer和AttackPlayer做同样的操作。在ChasePlayer中,我们可以将代理的目标设置为玩家的位置,而在AttackPlayer中,我们可以停止移动。在这种情况下,攻击玩家可以再次回到GoToBase或ChasePlayer,因此您需要在这些状态中将isStopped代理字段设置为false或在进行转换之前。我们将选择前者,因为这个版本将涵盖其他不需要额外代码就停止代理的状态。我们将从GoToBase状态开始:

图 9.60:重新激活代理
- 然后,继续使用
ChasePlayer:

图 9.61:重新激活代理并追逐玩家
- 最后,继续使用
AttackPlayer:

图 9.62:停止移动
-
您可以调整
NavMeshAgent的加速度、速度和角速度属性来控制敌人移动的速度。同时,请记住将更改应用到生成的敌人的 Prefab 上,以便受到影响。 -
关于视觉脚本版本,
GoToBase将看起来如下截图所示:
图 9.63:使我们的代理移动
-
我们删除了打印消息的 OnUpdate 事件节点,因为我们不再需要它了。此外,我们在设置变量
if为null后以及变量不是null(Null 检查的 Not Null 插针)时调用了 Set Destination 节点。请注意,所有这些都在 On Enter State 事件中发生,所以我们只需要做一次。在 C# 版本中,为了简单起见,我们每帧都这样做,但实际上这是不必要的,所以我们将利用 OnEnterState 事件。如果我们想,我们可以在改变状态的时刻(在检查转换条件的If语句中)执行这些操作,而不是使用 Update 函数。最后,注意我们为什么需要使用 GetParent 节点来访问敌人根对象中的NavMeshAgent组件?这是因为我们目前处于 AI 子对象中。 -
现在,AttackBase 状态将如下所示:

图 9.64:使我们的代理停止
- ChasePlayer 状态将如下所示:

图 9.65:ChasePlayer 逻辑
- 最后,AttackPlayer 如此:

图 9.66:AttackPlayer 逻辑
现在我们已经在我们的敌人中实现了移动,让我们完成我们 AI 的最终细节。
添加最终细节
这里我们缺少两件事:敌人没有射击任何子弹,并且它没有动画。让我们从修复射击开始,按照以下步骤操作:
-
在我们的
EnemyFSM脚本中添加一个GameObject类型的bulletPrefab字段和一个名为fireRate的float字段。 -
创建一个名为
Shoot的函数,并在AttackBase和AttackPlayer中调用它:

图 9.67:射击函数调用
- 在
Shoot函数中,放入与PlayerShooting脚本中用于以特定射击速率射击子弹的类似代码,如 图 9.68 所示。记住,如果你的 Enemy 预制件中还没有设置,请设置 Enemy 层,以防止子弹伤害到敌人本身。你可能还想稍微提高 AI GameObject 的位置,以便从地面或其他位置射击子弹,更好的方法是添加一个shootPoint变换字段,并在敌人中创建一个空对象作为生成位置。如果你这样做,考虑使空对象不旋转,这样敌人的旋转就能正确地影响子弹的方向:

图 9.68:Shoot 函数代码
在这里,你会在 PlayerShooting 和 EnemyFSM 之间找到一些重复的射击行为。你可以通过创建一个名为 Shoot 的函数的 Weapon 行为来修复它,该函数实例化子弹并考虑射击速率,并在两个组件内部调用它以重新利用它。
- 当代理停止时,不仅移动停止,旋转也会停止。如果玩家在敌人被攻击时移动,我们仍然需要敌人面对玩家以射击子弹。我们可以创建一个
LookTo函数,该函数接收要看的靶位并调用它,在AttackPlayer和AttackBase中调用它,并将射击的目标传递给它:

图 9.69:LookTo 函数调用
- 通过计算从父对象到靶位的方向来完成
LookTo函数。我们通过transform.parent访问我们的父对象,因为,记得,我们是子 AI 对象——将要移动的是我们的父对象。然后,我们将方向的Y分量设置为0以防止方向向上或向下指——我们不希望我们的敌人垂直旋转。最后,我们将父对象的向前向量设置为该方向,这样它就会立即面对靶位。如果你想有一个更平滑的旋转,你可以用四元数插值来替换它,但现在让我们尽量保持简单:

图 9.70:朝向目标看去
- 关于视觉脚本版本,AttackBase动作看起来是这样的:

图 9.71:AttackBase 状态
在这个状态下,有一些要点需要强调。首先,我们在SetStopped节点之后使用OnEnterState事件节点中的LookAt节点。正如你可能想象的那样,这与我们在 C#中使用的数学方法做的是一样的。我们指定一个要看的靶位(我们的基地变换)然后我们指定World Up参数是一个向上指的向量0,1,0。这将使我们的对象看向基地,但保持其向上向量指向天空,这意味着如果目标低于我们,我们的对象不会看向地面。如果我们想在 C#中使用这个函数(transform.LookAt),这个想法只是想展示所有选项。此外,请注意,我们只在状态变为活动状态时执行LookAt——因为基地不会移动,我们不需要不断更新我们的方向。
第二个需要强调的是,我们使用了协程来射击,这与我们在Enemy Spawner中不断生成敌人的相同思路。本质上,我们在Wait For Seconds和Instantiate之间创建了一个无限循环。我们采取这种方法是因为在视觉脚本中它更方便,因为它需要的节点更少。
记得选择OnEnterState节点并检查Coroutine复选框,就像我们之前做的那样。此外,我们还需要在敌人的 AI 子对象中添加一个新的 Float 类型变量,名为fireRate:

图 9.72:协程
然后,AttackPlayer将看起来像这样:

图 9.73:AttackPlayer 状态
实质上它与AttackBase相同,但它关注的是sensedObject而不是玩家的基地,我们还把LookAt节点作为无限循环的一部分,以便在射击前纠正敌人的航向,以便瞄准玩家。
这样,我们就完成了所有的 AI 行为。当然,这些脚本/图足够大,值得未来进行一些重构和拆分,但有了这个,我们已经原型化了我们的 AI,我们可以测试它,直到我们对它满意,然后我们可以改进这段代码。
摘要
我很确定 AI 不是你想象中的样子;你在这里不是在创建 Skynet,但我们已经实现了一个简单但有趣的 AI 来挑战我们的玩家,我们可以迭代和调整它以适应我们游戏预期的行为。我们看到了如何通过传感器收集周围信息来做出决策,决定执行什么动作,我们使用了不同的 Unity 系统,如寻路系统,使 AI 执行这些动作。我们使用这些系统来绘制一个能够检测玩家、向他们跑去并攻击他们的状态机,如果玩家不在那里,就只需前往基地完成其任务,摧毁它。
在下一章中,我们将开始本书的第三部分,我们将学习不同的 Unity 系统来提升我们游戏中的图形和音频效果,首先我们会看到如何创建材质来修改我们物体的外观,以及如何使用 Shader Graph 创建着色器。
第十章:使用 URP 和 Shader Graph 创建材料和效果
欢迎来到第三部分的第一章。在这里,我们将深入探讨 Unity 的不同图形和音频系统,以显著提升游戏的外观和感觉。我们将从讨论什么是着色器以及如何创建自己的着色器以实现一些默认 Unity 着色器无法实现的自定义效果开始。我们将使用 Shader Graph 创建一个简单的水面动画效果,Shader Graph 是包含在通用渲染管线(URP)中的可视化着色器编辑器。也称为 URP,这是 Unity 中可用的不同渲染管线之一,它提供面向性能的渲染功能。在本章中,我们将讨论其一些功能。
在本章中,我们将探讨以下着色器概念:
-
介绍着色器和 URP
-
使用 Shader Graph 创建着色器
介绍着色器和 URP
我们在书的第一部分中创建了材料,但我们从未讨论过它们内部是如何工作的,以及为什么它们的着色器属性很重要。在本章的第一部分,我们将探讨着色器的概念,作为编程显卡以实现自定义视觉效果的一种方式。我们还将讨论 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 如何使用渲染管线渲染所有对象。
渲染管线和 URP
我们已经介绍了视频卡如何渲染一个对象,但 Unity 负责请求视频卡为每个对象执行其着色器管线。为此,Unity 需要进行大量的准备和计算来确定每个着色器确切地何时以及如何执行。执行这项任务的正是 Unity 所说的渲染管线。
渲染管线是一种绘制场景中对象的方式。一开始,这听起来应该只有一个简单的方法来做这件事,例如,遍历场景中的所有对象并使用每个对象材质中指定的着色器执行着色器管线,但它可能比这更复杂。
通常,一个渲染管线与另一个渲染管线之间的主要区别在于光照和一些高级效果的计算方式,但它们在其他方面也可能有所不同。
在之前的 Unity 版本中,只有一个单一的渲染管线,现在被称为内置渲染管线(也称为BIRP)。这是一个包含了你可能需要用于所有类型项目的所有可能功能的管线,从移动 2D 图形和简单的 3D 到像在游戏机或高端 PC 上找到的尖端 3D。这听起来很理想,但实际上并非如此。拥有一个需要高度可定制以适应所有可能场景的单一大渲染器会产生大量的开销和限制,这比创建自定义渲染管线要头疼得多。幸运的是,Unity 的最新版本引入了可脚本化渲染管线(SRP),这是一种为你的项目创建适配的渲染管线的方式。
幸运的是,Unity 不希望你为每个项目创建自己的渲染管线(这是一个复杂的工作),因此它为你创建了两个可用的自定义管线:URP(以前称为 LWRP),代表通用渲染管线,以及HDRP,代表高清渲染管线。想法是,你必须根据项目需求选择其中一个(除非你真的需要创建自己的)。
URP,我们在创建游戏项目时选择的一个渲染管线,适用于大多数不需要大量高级图形特性的游戏,如移动游戏或简单的 PC 游戏,而 HDRP 则包含许多用于高质量游戏的高级渲染特性。后者需要高端硬件才能运行,而 URP 几乎可以在所有相关目标设备上运行。值得一提的是,你可以随时在内置渲染器、HDRP 和 URP 之间切换,包括在创建项目之后(但不推荐这样做):

图 10.4:项目向导显示 HDRP 和 URP 模板
我们可以讨论每个着色器的实现方式和它们之间的差异,但同样,这可能会填满整个章节;目前,本节的想法是让你知道为什么我们选择 URP 来创建我们的项目,因为它有一些我们在本书中会遇到并需要考虑的限制,所以了解我们接受这些限制的原因(为了在所有相关硬件上运行我们的游戏)是很好的。
此外,我们需要知道我们选择 URP 是因为它支持 Shader Graph,这是我们将在本章中使用 Unity 工具来创建自定义效果的工具。之前的 Unity 内置管线没有提供这样的工具(除了第三方插件)。最后,引入 URP 概念的另一个原因是它附带了许多内置着色器,在创建我们自己的着色器之前,我们需要了解它们,以避免重复造轮子。这将使我们熟悉这些着色器,因为如果你来自 Unity 的早期版本,你已知的着色器在这里将不起作用;实际上,这正是我们将在本章下一节中讨论的内容:不同 URP 内置着色器之间的区别。
URP 内置着色器
现在我们已经了解了 URP 与其他管线之间的区别,让我们讨论哪些着色器集成到了 URP 中。让我们简要描述这个管线中三个最重要的着色器:
-
Lit: 这是旧标准着色器的替代品。这个着色器适用于创建各种逼真的物理材料,如木材、橡胶、金属、皮肤以及它们的组合(如皮肤和金属盔甲的角色)。它支持诸如法线贴图、遮挡、金属和镜面等不同的光照工作流程,以及透明度。
-
Simple Lit:这是旧版 Mobile/Diffuse Shader 的替代品。正如其名所示,这个着色器是 Lit 的一个简化版本,意味着它的光照计算是对光照工作原理的简单近似,比其对应版本功能更少。基本上,当你有简单的图形而没有真实的光照效果时,这是最佳选择。
-
Unlit:这是旧版 Unlit/Texture Shader 的替代品。有时,你需要完全没有光照的对象,在这种情况下,这个着色器就是为你准备的。没有光照并不意味着没有光或完全黑暗;实际上,这意味着对象完全没有阴影,并且在没有阴影的情况下完全可见。一些简单的图形可以使用这个着色器,依靠纹理中烘焙的阴影,这意味着纹理自带阴影。
这在性能上非常出色,特别是对于低端设备,如手机。还有其他情况,比如光管或屏幕,这些不能接收阴影的对象因为它们会发光,所以即使在完全黑暗中,它们也会以全色显示。在下面的屏幕截图中,你可以看到一个使用 Unlit Shader 的 3D 模型。它看起来像是有光照,但实际上只是模型纹理在不同部分应用了浅色和深色,使其看起来像是有光照:

图 10.5:使用未光照效果模拟廉价光照的 Pod
让我们使用 Simple Lit Shader 做一个有趣的分解效果来展示其功能。你必须做以下几步:
- 从任何搜索引擎下载并导入云噪声纹理:

图 10.6:噪声纹理
-
在项目面板中选择最近导入的纹理。
-
在检查器中,将Alpha Source属性设置为从灰度。这将使纹理的 alpha 通道根据图像的灰度进行计算:

图 10.7:从灰度生成 Alpha 纹理设置
颜色的 Alpha 通道通常与透明度相关联,但你会注意到我们的对象不会是透明的。Alpha 通道是额外的颜色数据,在创建效果时可以用于多种目的。在这种情况下,我们将使用它来确定哪些像素首先被分解。
- 在项目视图中点击+图标,并选择材质:

图 10.8:材质创建按钮
- 通过访问GameObject | 3D Object | Cube创建一个立方体:

图 10.9:立方体原形创建
-
将材质从项目窗口拖动到场景窗口中的立方体上。
-
在检查器中Shader属性右侧的下拉菜单中点击,查找Universal Render Pipeline | Simple Lit选项。我们也可以使用默认的着色器(Lit),但Simple Lit在性能上会更简单,我们也不会使用 Lit 的高级功能:

图 10.10:简单光照着色器选择
-
选择材质并将下载的云纹理拖到基础贴图左侧的矩形中。
-
打开Alpha 裁剪复选框并将阈值滑块设置为
0.5:

图 10.11:Alpha 裁剪阈值材质滑块
- 当你移动阈值滑块时,物体将开始分解。Alpha 裁剪会丢弃 Alpha 强度低于阈值值的像素:

图 10.12:带有 Alpha 裁剪的分解效果
- 最后,将渲染面设置为两个面以查看立方体面的两侧:

图 10.13:双面渲染面
- 请注意,创建纹理的艺术家可以手动配置 Alpha 通道,而不是从灰度值计算它,以便精确控制分解效果的外观,无论纹理的颜色分布如何:

图 10.14:双面 Alpha 裁剪
本节的目的不是提供所有 URP 着色器属性的全面指南,而是给你一个概念,即当着色器配置得当以及何时使用每个集成着色器时,着色器可以做什么。有时,你只需使用现有的着色器就能达到所需的效果,在简单游戏中,这可能是 99%的情况,所以尽可能坚持使用它们。但如果你真的需要创建一个自定义着色器来创建一个非常特定的效果,下一节将教你如何使用 URP 工具 Shader Graph。
使用 Shader Graph 创建着色器
现在我们已经了解了着色器的工作原理和 URP 中现有的着色器,我们对何时需要创建自定义着色器以及何时不需要有了基本的概念。如果你真的需要创建一个,本节将涵盖使用 Shader Graph 创建效果的基础知识,Shader Graph 是一个使用可视化节点编辑器创建效果的工具。当你不习惯编码时,这是一个易于使用的工具。
在本节中,我们将讨论 Shader Graph 的以下概念:
-
创建我们的第一个 Shader Graph
-
使用纹理
-
合并纹理
-
应用透明度
-
创建顶点效果
让我们先看看我们如何创建和使用 Shader Graph。
创建我们的第一个 Shader Graph
Shader Graph 是一个允许我们使用基于节点的系统创建自定义效果的工具。Shader Graph 中的效果可以像以下截图所示:

图 10.15:带有创建自定义效果的节点的 Shader Graph
我们将在稍后讨论这些节点的作用,并将逐步创建一个示例效果,但在截图上,你可以看到作者如何创建和连接几个节点——相互连接的盒子——每个节点执行特定的过程以实现效果。使用 Shader 图创建效果的想法是学习你需要哪些特定的节点以及如何正确地连接它们。这与我们编写游戏玩法代码的方式类似,但这个 Shader 图是为了效果目的而调整和简化的。
要创建和编辑我们的第一个 Shader 图,请执行以下操作:
- 在项目窗口中,点击 + 图标,找到 Shader Graph | URP | Lit Shader Graph 选项。这将使用 PBR 模式创建一个 Shader 图,这意味着这个着色器将支持光照效果(与未光照图不同):

图 10.16:PBR Shader 图创建
- 命名为
Water。如果你想有机会重命名资产,请记住你可以选择资产,右键单击并选择 重命名:

图 10.17:Shader 图资产
- 创建一个新的材质,命名为
WaterMaterial,并将 Shader 设置为 Shader Graphs/Water。如果由于某种原因 Unity 不允许你这样做,尝试在 Water Graph 上右键单击并点击 Reimport。正如你所见,创建的 Shader 图现在在材质中显示为着色器:

图 10.18:设置 Shader 图为材质着色器
-
使用 GameObject | 3D Object | Plane 选项创建一个平面。
-
将 Material 拖到 Plane 上以应用它。
现在,你已经创建了你第一个自定义着色器并将其应用于材质。到目前为止,它看起来一点也不有趣——它只是一个灰色效果——但现在是你编辑图以解锁其全部潜力的时候了。正如图名所暗示的,在本章中我们将创建一个水效果来展示 Shader 图工具集的几个节点以及如何连接它们,所以让我们先从讨论主节点开始。
当你通过双击着色器资产打开图时,你会看到以下内容:

图 10.19:包含计算对象外观所需所有属性的 Master 节点
所有节点都将有输入引脚,工作所需的数据,以及输出引脚,其处理的结果。以加法运算为例,我们将有两个输入数字和一个输出数字,即加法的结果。在这种情况下,你可以看到主节点只包含输入,这是因为所有进入主节点的数据都将被 Unity 用于计算对象的渲染和光照,例如所需的对象颜色或纹理(基础颜色输入引脚)、平滑度(平滑度输入引脚)或金属含量(金属输入引脚),这些属性将影响光照如何应用于对象。
您可以看到主节点被分为顶点部分和片段部分。前者能够改变我们正在修改的对象的网格,以变形它、动画化它等,而后者将改变它的外观,使用的纹理,光照方式等。让我们通过以下步骤开始探索如何在片段部分更改这些数据:
-
双击项目视图中的Shader 图资产以打开其编辑器。
-
在基础颜色输入插针左侧的灰色矩形中点击:

图 10.20:基础颜色节点输入插针
- 在颜色选择器中,选择一种浅蓝色,如水。选择圆圈的蓝色部分,然后在中间矩形中选择该颜色的一个色调:

图 10.21:颜色选择器
- 将平滑度设置为
0.9,这将使对象几乎完全光滑(90%的总平滑度)。这将使我们的水几乎完全反射天空:

图 10.22:平滑度 PBR 主节点输入插针
- 点击窗口左上角的保存资产按钮:

图 10.23:Shader 图保存选项
- 返回场景视图并检查平面是否是浅蓝色,并且太阳反射在其上:

图 10.24:初始 Shader 图结果
如您所见,着色器的行为根据您在主节点中设置的属性而变化,但到目前为止,这样做与创建一个未光照着色器并设置其属性没有区别;Shader 图真正的力量在于您使用节点作为主节点的输入进行特定计算时。我们将开始查看纹理节点,这些节点允许我们将纹理应用于我们的模型。
使用纹理
使用纹理的想法是将图像应用于模型,以便我们可以用不同的颜色绘制模型的各个部分。请记住,模型有一个 UV 贴图,这使得 Unity 能够知道纹理的哪一部分将被应用于模型的哪一部分:

图 10.25:左侧是一个面纹理;右侧是将相同的纹理应用于面网格
我们有几个节点来完成这个任务,其中之一是 Sample Texture 2D,这是一个有两个主要输入的节点。首先,它要求我们提供要采样或应用于模型的纹理,然后是 UV。您可以在以下屏幕截图中看到它:

图 10.26:样本纹理 2D 节点
如您所见,纹理输入节点的默认值是None,因此默认情况下没有纹理,我们需要手动指定。对于UV,默认值是UV0,这意味着默认情况下,节点将使用模型的主要 UV 通道,而且是的,一个模型可以设置多个 UV。现在,我们将坚持使用主要的一个。如果您不确定这意味着什么,UV0 是最佳选择。让我们尝试这个节点,按照以下步骤操作:
- 从互联网下载并导入一个可重复纹理的水纹理:

图 10.27:可平铺的水纹理
- 选择纹理,并确保纹理的Wrap Mode属性设置为Repeat,这将允许我们像在地形中做的那样重复纹理,因为我们想使用这个着色器覆盖大面积的水域:

图 10.28:纹理重复模式
- 在Water Shader Graph中,在Shader Graph的空白区域右键单击,然后选择Create Node:

图 10.29:Shader Graph 创建节点选项
- 在搜索框中输入
Sample texture,所有采样节点将显示出来。如果由于某些原因无法双击选项,请先右键单击它,然后再次尝试。这个工具存在一个已知问题,这是解决方案:

图 10.30:样本纹理节点搜索
- 点击Sample Texture 2D节点Texture输入引脚左侧的圆圈。这将允许我们选择要采样的纹理——只需选择水纹理。您可以看到纹理可以在节点的底部部分预览:

图 10.31:样本纹理节点,其输入引脚中有纹理
- 将Sample Texture 2D节点的输出引脚RGBA拖到主节点的Base Color输入引脚:

图 10.32:将纹理采样的结果与主节点的 Base Color 引脚连接
- 在 Shader Graph 编辑器的左上角点击Save Asset按钮,然后在场景视图中查看更改:

图 10.33:在 Shader Graph 中应用纹理的结果
如您所见,纹理已正确应用于模型,但如果考虑到默认平面的尺寸为 10x10 米,水的波纹似乎太大。因此,让我们将纹理平铺!
要做到这一点,我们需要更改模型的 UV,使它们更大。您可能会想象更大的 UV 意味着纹理也应该更大,但请记住,我们并没有使对象变大;我们只是在修改 UV。
在同一个对象区域,我们将显示更多的纹理区域,这意味着在更大的纹理采样区域(通过更大的 UV 实现),纹理可能会出现重复。为此,请按照以下步骤操作:
- 在任何空白区域右键单击,然后点击New Node以搜索 UV 节点:

图 10.34:搜索 UV 节点
-
使用相同的方法,创建一个Multiply节点。
-
将 UV 节点的Out引脚拖到Multiply节点的A引脚上以连接它们。
-
将Multiply的B引脚输入值设置为
4,4,4,4:

图 10.35:将 UV 乘以 4
- 将Multiply节点的Out引脚拖到Sample Texture 2D节点的UV上以连接它们:

图 10.36:使用乘法 UV 坐标来采样纹理
- 如果您保存图表并返回场景视图,您会看到现在波纹更小,因为我们已经对模型的 UV 坐标进行了平铺。您也可以在2D 纹理采样器节点的预览中看到这一点:

图 10.37:模型 UV 乘法的结果
我们现在可以实现的另一个有趣的效果是对纹理应用偏移以移动它。想法是,即使平面实际上没有移动,我们也会模拟水流通过它,只是移动纹理。记住,确定将纹理的哪一部分应用到模型的每一部分的职责属于 UV,因此如果我们向 UV 坐标添加值,我们就会移动它们,从而生成纹理滑动效果。为了做到这一点,让我们做以下操作:
-
在UV节点右侧创建一个添加节点。
-
将UV的输出引脚连接到添加节点的A引脚:

图 10.38:向 UV 坐标添加值
-
在添加节点左侧创建一个时间节点。
-
将时间节点连接到添加节点的B引脚:

图 10.39:将时间添加到 UV 坐标
- 将添加节点的输出引脚连接到乘法节点的A输入引脚:


- 保存并查看场景视图中水的移动。如果您看不到移动,请点击场景顶部栏中的图层图标并检查始终刷新:
如果你觉得水流动得太快,尝试使用乘法节点将时间值设得更小。我建议你在查看下一张截图(其中包含答案)之前亲自尝试一下:
图 10.41:启用“始终刷新”以预览效果

图 10.42:乘以时间以减慢纹理移动速度
- 如果你觉得图表太大,尝试通过点击鼠标悬停时出现在预览上的向上(^)箭头,隐藏一些节点预览:

图 10.43:隐藏图表节点预览
- 此外,您还可以通过选择节点并点击其右上角的箭头来隐藏未使用的引脚:

图 10.44:隐藏图表节点中的未使用引脚
因此,总结一下,我们首先将时间添加到 UV 坐标以移动它,然后将移动后的 UV 坐标的结果进行乘法操作以使其更大,从而平铺纹理。值得一提的是,有一个平铺和偏移节点可以为我们完成所有这些过程,但我想要展示的是,一个简单的乘法操作可以缩放 UV,一个加法操作可以移动它,从而产生一个很好的效果;您无法想象您可以用其他简单的数学节点实现多少种可能的效果!实际上,让我们在下一节中探索数学节点结合纹理的其他用法。
纹理组合
尽管我们使用了节点,但我们并没有创建出任何不能通过常规着色器创建的东西,但这一点即将改变。到目前为止,我们可以看到水在移动,但它仍然看起来很静止,这是因为波纹始终是相同的。我们有几种生成波纹的技术,最简单的一种是将两个在不同方向上移动的水纹理组合起来,以混合它们的波纹,实际上,我们可以简单地使用相同的纹理,只需将其翻转以节省一些内存。为了组合纹理,我们将它们相加,然后除以 2,所以基本上,我们是在计算纹理的平均值!让我们通过以下步骤来实现:
- 选择Time和Sampler 2D之间的所有节点(包括它们),通过在图中任何空白区域点击,按住并拖动点击,然后在所有目标节点都被覆盖时释放来创建一个选择矩形:

图 10.45:选择多个节点
-
右键单击并选择复制,然后再次右键单击并选择粘贴,或者使用经典的Ctrl + C,Ctrl + V命令(在 Mac 上为Command + C,Command + V)。
-
将复制的节点移动到原始节点下方:

图 10.46:节点的复制
-
对于复制的节点,将连接到Sample Texture 2D的Multiply节点的B引脚设置为
-4,-4,-4,-4。你可以看到这翻转了纹理。 -
同时,将连接到Time节点的Multiply节点的B引脚设置为
-0.1:

图 10.47:值的乘法
- 在两个Sampler Texture 2D节点的右侧创建一个Add节点,并将这些节点的输出连接到Add节点的A和B输入引脚:

图 10.48:添加两个纹理
- 你可以看到,由于我们同时计算了两种纹理的强度,所以得到的结果太亮了。因此,我们可以通过将Add节点的Out乘以
0.5,0.5,0.5,0.5来解决这个问题,这将把每个结果颜色通道除以 2,实现颜色的平均。如果你愿意,也可以尝试设置每个通道的不同值来观察会发生什么,但就我们的目的而言,0.5是每个通道的正确值:

图 10.49:将两个纹理的和除以得到平均值
-
将Multiply节点的Out引脚连接到Master节点的Base Color引脚,以将所有这些计算应用于物体的颜色。
-
保存Asset并在场景视图中查看结果:

图 10.50:纹理混合的结果
您可以继续添加节点以使效果更加多样化,例如使用正弦节点(这将执行三角函数正弦操作)来应用非线性运动等,但我会让您通过自己实验来学习这一点。现在,我们将在这里停止。像往常一样,这个主题值得一本完整的书,而本章的目的是给您这个强大的 Unity 工具的一个小尝鲜。我建议您在网上寻找其他 Shader Graph 示例,以学习相同节点的其他用法,当然,还有新节点。在这里要考虑的一点是我们刚刚所做的一切基本上都应用于我们之前讨论的 Shader Pipeline 的片段着色器阶段。现在,让我们使用混合着色器阶段来给水应用一些透明度。
应用透明度
在宣布我们的效果完成之前,我们可以做的一个小补充是使水稍微透明一些。请记住,Shader Pipeline 有一个混合阶段,该阶段负责将我们的模型中的每个像素混合到当前帧正在渲染的图像中。我们的想法是让我们的 Shader Graph 修改这个阶段以应用Alpha 混合,这是一种基于我们模型 Alpha 值的混合模式。
要达到这种效果,请执行以下步骤:
-
寻找漂浮的图形检查器窗口。如果您看不到它,请点击 Shader Graph 编辑器右上角的图形检查器按钮。
-
点击图形设置选项卡。
-
将表面类型属性设置为透明。
-
如果混合模式属性尚未设置为该值,请将其设置为Alpha:

图 10.51:图形检查器透明度设置
- 将主的Alpha输入引脚设置为
0.5。

图 10.52:设置主节点的 Alpha
- 保存 Shader Graph,并在场景视图中查看应用的透明度。如果您看不到效果,只需将一个立方体放入水中,使效果更加明显:

图 10.53:水产生的阴影应用到立方体上
- 您可以看到水对我们的立方体投射的阴影,因为 Unity 不知道该对象是透明的,因此会投射阴影。点击水面,在检查器中查找 Mesh Renderer 组件。如果您看不到阴影,请点击场景视图顶部的灯泡图标。

图 10.54:在场景视图中启用灯光
- 在照明部分,将投射阴影设置为关闭;这将禁用从水面投射到水下立方体部分的阴影:

图 10.55:禁用阴影投射
添加透明度是一个简单的过程,但它有一些注意事项,比如阴影问题,在更复杂的情况下,它可能还有其他问题,比如过度绘制,这意味着同一个像素需要绘制多次(属于透明对象的像素,以及背后的一个对象)。我建议除非必要,否则避免使用透明度。实际上,我们的水可以不使用透明度,尤其是在我们将这种水应用到基础周围的河盆地时,因为我们不需要看到水下部分,但目的是让你知道所有选项。在下一张截图中,你可以看到我们如何在基础下方放置了一个带有这种效果的大平面,足够大,可以覆盖整个盆地:

图 10.56:在主场景中使用我们的水
现在我们已经修改了通过片段节点部分对象的外观,让我们讨论如何使用顶点部分来对我们的水应用网格动画。
创建顶点效果
到目前为止,我们已经将水纹理应用到我们的水上,但它仍然是一个平面。我们可以更进一步,不仅通过纹理,还通过动画网格来制作涟漪。为此,我们将在着色器中应用我们在本章开头使用的噪声纹理,但不是将其用作添加到着色器基础颜色的另一种颜色,而是用它来偏移我们平面的顶点的Y位置。
由于噪声纹理的混沌性质,我们的想法是对模型的不同部分应用垂直偏移,这样我们就可以模拟涟漪:

图 10.57:默认平面网格细分为 10x10 的网格,无偏移
要实现类似的效果,你可以修改你的着色器的顶点部分,使其看起来如下:

图 10.58:涟漪顶点效果
在图中,你可以看到我们是如何创建一个向量的,其y轴取决于我们在本章开头下载的噪声纹理。背后的想法是创建一个指向上方的向量,其长度与纹理的灰度因子成正比;纹理的像素越白,偏移量越长。这种纹理具有不规则但平滑的图案,可以模拟潮汐的行为。
请注意,这里我们使用了Sample Texture 2D LOD而不是Sample Texture 2D;后者在顶点部分不起作用,所以请记住这一点。
然后我们将结果乘以0.3以减少要添加的偏移量的高度,然后将结果添加到位置节点。注意位置节点的空间属性设置为对象模式。我们需要这种模式来与着色器图(我们在第二章,编辑场景和游戏对象中讨论了世界和本地空间,但你也可以在网上搜索对象 vs 世界空间以获取更多关于此的信息)。最后,结果连接到顶点部分的位置节点。
如果你保存,你会看到以下类似图像:

图 10.59:应用了涟漪顶点效果
当然,在这种情况下,涟漪是静态的,因为我们没有像之前那样给 UV 添加任何时间偏移。在下面的屏幕截图中,你可以看到如何添加它,但在查看它之前,我建议你先自己尝试解决它,作为一个个人挑战:

图 10.60:动画涟漪顶点效果图
如你所见,我们再次使用原始 UV,并添加任何因子的乘以时间,这样它会慢慢移动,就像我们之前在水面纹理中所做的那样。你可以继续尝试不同的纹理,改变其外观,乘以偏移量以增加或减少涟漪的高度,应用如正弦等有趣的数学函数,等等,但就目前而言,让我们完成这个。
摘要
在本章中,我们讨论了着色器在 GPU 中的工作方式以及如何创建我们的第一个简单着色器以实现良好的水面效果。与着色器一起工作是一项复杂而有趣的工作,在一个团队中,通常有一到多个人负责创建所有这些效果,这个职位被称为技术艺术家;所以,正如你所见,这个主题可以扩展到整个职业生涯。记住,这本书的目的是给你一个行业所有可能角色的微小品尝,所以如果你真的喜欢这个角色,我建议你开始阅读专门关于着色器的书籍。你面前有一条漫长但超级有趣的道路。
现在已经足够了着色器了!在下一章中,我们将探讨如何通过粒子系统来改进我们的图形并创建视觉效果!
第十一章:使用粒子系统和视觉效果图进行视觉效果
在本章中,我们将继续学习我们游戏中的视觉效果。我们将讨论粒子系统,这是一种模拟火焰、瀑布、烟雾和各种流体的方法。此外,我们还将看到两个 Unity 粒子系统来创建这些效果,Shuriken和视觉效果图,后者比前者更强大,但需要更多的硬件。
在本章中,我们将涵盖以下粒子系统主题:
-
Shuriken 粒子系统简介
-
创建流体模拟
-
使用视觉效果图创建复杂模拟
Shuriken 粒子系统简介
我们迄今为止创建的所有图形和效果都使用静态网格——不能以任何方式倾斜、弯曲或变形的 3D 模型。流体如火焰和烟雾显然不能使用这种网格表示,但实际上,我们可以通过静态网格的组合来模拟这些效果,这就是粒子系统发挥作用的地方。
粒子系统是发射和动画化大量粒子或标牌的对象,这些是面向摄像机的简单四边形网格。每个粒子是一个静态网格,但渲染、动画和组合大量粒子可以产生流体的幻觉。
在图 11.1中,你可以看到左侧使用粒子系统创建的烟雾效果,右侧是相同粒子的线框视图。在那里,你可以看到创建烟雾幻觉的四边形,这是通过将烟雾纹理应用到每个粒子并对其动画化来实现的,使它们从底部产生并向上随机移动:

图 11.1:左侧,烟雾粒子系统;右侧,相同系统的线框
在本节中,我们将涵盖与粒子相关的以下主题:
-
使用 Shuriken 创建基本粒子系统
-
使用高级模块
让我们先讨论如何使用 Shuriken 创建我们第一个粒子系统。
使用 Shuriken 创建基本粒子系统
为了说明粒子系统的创建,让我们创建一个爆炸效果。想法是同时产生大量粒子并将它们向所有方向扩散。让我们从创建 Shuriken 粒子系统并配置它提供的基本设置开始,以改变其默认行为。为此,请按照以下步骤操作:
- 选择GameObject | Effects | Particle System选项:

图 11.2:粒子系统按钮
- 你应该在下面的屏幕截图中看到效果。默认行为是一列粒子向上移动,就像之前显示的烟雾效果。让我们改变一下:

图 11.3:默认粒子系统外观
-
在场景中单击创建的对象并查看检查器。
-
通过点击标题打开形状部分。在这里,你可以指定粒子发射器的形状,粒子将从该形状中产生。
-
将形状属性更改为球体。现在粒子应该向所有可能的方向移动,而不是遵循默认的圆锥:

图 11.4:形状属性
-
在粒子系统模块(通常称为主模块)中将起始速度设置为
10。这将使粒子移动得更快。 -
在相同的模块中,将起始寿命设置为
0.5。这指定了粒子将存活多长时间。在这种情况下,我们给了一个半秒的寿命。与速度(每秒 10 米)结合,这使得粒子在移动 5 米后消失:

图 11.5:主粒子系统模块
-
打开发射模块并将随时间变化率设置为
0。此属性指定每秒将发射多少粒子,但对于爆炸,我们实际上需要一个粒子爆发,所以在这种情况下我们不会随时间持续发射粒子。 -
在爆发列表中,点击底部的+按钮,然后在列表中创建的项目中,将计数列设置为
100:

图 11.6:发射模块
- 在主模块(标题为粒子系统)中将持续时间设置为
1并取消勾选循环。在我们的情况下,爆炸不会持续重复;我们只需要一次爆炸:

图 11.7:循环复选框
- 现在粒子不再循环,你需要手动点击场景视图右下角的粒子效果窗口中显示的播放按钮来查看系统。如果你看不到那个窗口,请记住首先在层次结构中选择带有粒子系统的 GameObject。

图 11.8:粒子系统播放控制
- 将停止动作设置为销毁。当持续时间时间过去时,这将销毁对象。这仅在运行游戏时才会起作用,因此你可以在编辑场景时安全地使用此配置:

图 11.9:停止动作设置为销毁
- 将主模块的起始大小设置为
3。这将使粒子更大,看起来更密集:

图 11.10:粒子系统起始大小
-
点击主模块中起始旋转属性右侧的向下箭头并选择在两个常量之间随机。
-
在步骤 14 之后出现的两个输入值中将起始旋转设置为
0和360。这允许我们在粒子生成时给它们一个随机旋转,使它们看起来略有不同:

图 11.11:随机起始旋转
-
现在粒子表现如预期,但看起来并不如预期。让我们改变一下。通过在项目视图中点击+图标并选择材质来创建一个新的材质。命名为
Explosion。 -
将其着色器设置为Universal Render Pipeline/Particles/Unlit。这是一个特殊的着色器,用于将纹理应用到 Shuriken 粒子系统:

图 11.12:粒子系统材质着色器
- 从互联网或资源商店下载烟雾粒子纹理。在这种情况下,下载一个黑色背景的纹理很重要;忽略其他纹理:

图 11.13:烟雾粒子纹理
-
将此纹理设置为材料的基础图。
-
将表面类型设置为透明,并将混合模式设置为添加。这样做会使粒子相互融合,而不是相互绘制,以模拟大量烟雾而不是单个烟雾团。我们使用添加模式,因为我们的纹理有黑色背景,并且我们想要创建一个光照效果(爆炸会使场景变亮):

图 11.14:粒子的表面选项
- 将您的材质拖动到渲染器模块的材质属性:

图 11.15:粒子材质设置
- 现在您的系统应该看起来像以下图所示:

图 11.16:前述设置的输出结果
通过这些步骤,我们已经改变了粒子或广告牌的生成方式(使用发射模块),它们将向哪个方向移动(使用形状模块),它们的移动速度,它们的持续时间,它们的大小(使用主模块),以及它们的形状(使用渲染器模块)。创建粒子系统是正确配置它们不同设置的一个简单案例。当然,正确地做到这一点本身就是一门艺术;它需要创造力和了解如何使用它们提供的所有设置和配置。因此,为了提高我们的技能集,让我们讨论一些高级模块。
使用高级模块
我们的系统看起来不错,但我们还可以大幅改进它,所以让我们启用一些新模块来提高其质量:
- 在颜色随寿命的左侧复选框中勾选以启用它:

图 11.17:启用颜色随寿命模块
-
通过点击标题打开模块,然后点击颜色属性右侧的白色条。这将打开渐变编辑器。
-
在条形图左上角的白色标记略微向右点击以创建一个新的标记。同样,在条形图右上角的白色标记略微向左点击以创建第四个标记。这些标记将允许我们指定粒子在其生命周期中的透明度:

图 11.18:颜色随寿命渐变编辑器
-
如果创建了不需要的标记,只需将它们拖出窗口即可删除。
-
点击左上角的标记(不是我们创建的那个,而是已经存在的那个)并将底部的Alpha滑块设置为
0。按照以下截图所示,对右上角的标记也进行同样的操作。现在你应该会看到粒子在爆炸结束时逐渐消失,而不是突然消失:

图 11.19:淡入和淡出渐变
-
通过点击复选框启用寿命内限制速度模块。
-
将阻尼设置调整为
0.1。这将使粒子缓慢停止而不是继续移动:

图 11.20:阻尼速度以使粒子停止
- 启用寿命内的旋转并设置角速度在
-90和90之间。请记住,您应该通过点击属性右侧的向下箭头在在两个常量之间随机中设置值。现在粒子应该在它们的寿命期间旋转以模拟更多运动:

图 11.21:随机旋转速度
在我们创建粒子时,在主模块中设置的寿命很短,因此这些效果将非常微妙。您可以随意增加寿命值以更详细地查看这些效果,但请注意,如果您频繁地生成粒子,这可能会导致粒子数量过多,从而降低性能。只是要注意调整这些值时它们对性能的影响。
如您所见,有许多额外的模块可以启用和禁用,以在现有模块之上添加行为层,因此再次,要创造性地使用它们来创建各种效果。请记住,您可以为这些系统创建 Prefab 以在场景中复制它们。我还建议在 Asset Store 中搜索并下载粒子效果,以了解其他人如何使用相同的系统创建惊人的效果。看到各种不同的系统是学习如何创建它们的最佳方式,这就是我们在下一节将要做的:创建更多系统!
创建流体模拟
正如我们所说的,学习如何创建粒子系统的最佳方式是继续寻找已经创建的粒子系统,并探索人们如何使用各种系统设置来创建完全不同的模拟。
在本节中,我们将学习如何使用粒子系统创建以下效果:
-
瀑布效果
-
篝火效果
让我们从最简单的一个开始,瀑布效果。
创建瀑布效果
为了做到这一点,请按照以下步骤操作:
-
创建一个新的粒子系统(GameObject | Effects | Particle System)。
-
在形状模块中将形状设置为边缘,并将其半径设置为
5。这将使粒子沿着发射线生成:

图 11.22:边缘形状
-
将发射模块的寿命内速率设置为
50。 -
将主模块的起始大小设置为
3,将起始寿命设置为3:

图 11.23:主模块设置
- 将主模块的重力修改器设置为
0.5。这将使粒子下落:

图 11.24:主模块中的重力修改器
- 使用我们之前创建的相同的
Explosion材质为这个系统:

图 11.25:爆炸粒子材质
-
启用生命周期内颜色并打开渐变编辑器。
-
点击右下角的标记,这次你应该看到一个颜色选择器而不是 alpha 滑块。顶部的标记允许你随时间改变透明度,而底部的标记随时间改变粒子的颜色。在这个标记中设置浅蓝色:

图 11.26:从白色到浅蓝色的渐变
作为挑战,我建议你添加一个小粒子系统,在这个粒子系统结束的地方创建一些水花,模拟水与底部湖泊的碰撞。现在我们可以将这个粒子系统添加到场景中的一个山丘上以装饰它,就像以下截图所示。我已经稍微调整了系统,使其在这个场景中看起来更好。我挑战你自己调整它,使其看起来像这样:

图 11.27:瀑布粒子系统应用于当前场景
现在,让我们创建另一个效果:篝火。
创建篝火效果
为了创建篝火,请执行以下操作:
-
创建一个粒子系统,就像我们在使用 Shuriken 创建基本粒子系统部分所做的那样,在GameObject | Effects | Particle System中。
-
在互联网或资源商店上寻找一个火焰粒子纹理图。这种纹理应该看起来像不同火焰纹理的网格。想法是将火焰动画应用到我们的粒子中,交换所有这些小纹理:

图 11.28:粒子纹理精灵图
-
创建一个使用Universal Render Pipeline/Particles/Unlit着色器的粒子材质。
-
将火焰精灵图纹理设置为基础图。
-
将基础图右侧的颜色设置为白色。
-
将此材质设置为粒子材质。请记住将表面类型设置为透明,并将混合模式设置为叠加:

图 11.29:带有粒子精灵图的材质
- 启用纹理图动画模块,并根据您的火焰图设置瓷砖属性。在我的情况下,我有一个 4x4 精灵的网格,所以我将
4放在X上,将4放在Y上。之后,你应该会看到粒子在交换纹理:

图 11.30:启用纹理图动画
-
在主模块中将起始速度设置为
0,将起始大小设置为1.5。 -
在形状中设置半径为
0.5。 -
创建第二个粒子系统并将其设置为火焰系统的子系统:

图 11.31:父子粒子系统
-
应用爆炸示例中的爆炸材质。
-
在形状模块中将角度设置为
0,将半径设置为0.5。
系统应该看起来像这样:

图 11.32:结合火焰和烟雾粒子系统的结果
如你所见,你可以组合几个粒子系统来创建一个单一的效果。在做这件事时要小心,因为很容易发射过多的粒子并影响游戏性能。粒子并不便宜,如果你对它们不够谨慎,可能会降低游戏的FPS(每秒帧数)。
到目前为止,我们已经探索了你可以用来创建这类效果的一种 Unity 系统,虽然这个系统对于大多数情况来说已经足够,但 Unity 最近发布了一个可以生成更复杂效果的新系统,称为视觉效果图。让我们看看如何使用它,以及它与 Shuriken 有何不同。
使用视觉效果图创建复杂模拟
我们迄今为止使用的粒子系统被称为 Shuriken,它处理所有在 CPU 上的计算。这既有优点也有缺点。优点是它可以在 Unity 支持的所有可能的设备上运行,无论它们的性能如何(它们都有 CPU),但缺点是如果我们对发射的粒子数量不够谨慎,我们很容易超过 CPU 的能力。现代游戏需要更复杂的粒子系统来生成可信的效果,而这种基于 CPU 的粒子系统解决方案已经开始达到其极限。这就是视觉效果图发挥作用的地方:

图 11.33:左侧是一个大规模粒子系统,右侧是视觉效果图的示例
视觉效果图是一个基于 GPU 的粒子系统解决方案,这意味着系统是在显卡上而不是 CPU 上执行的。这是因为显卡在执行大量小模拟方面要高效得多,比如系统中的每个粒子都需要,所以我们可以在 GPU 上达到比 CPU 更高的粒子数量级别。这里的缺点是我们需要一个具有计算着色器功能的相当现代的 GPU 来支持这个系统,因此我们将排除使用这个系统的一些目标平台(忘记大多数手机),所以如果你的目标平台支持它(中高端 PC、游戏机和一些高端手机),请使用它。
在本节中,我们将讨论以下关于视觉效果图的主题:
-
安装视觉效果图
-
创建和分析视觉效果图
-
创建雨效果
让我们先看看我们如何在项目中添加对视觉效果图的支持。
安装视觉效果图
到目前为止,我们已经使用了大量已经安装在我们项目中的 Unity 功能,但 Unity 可以通过大量插件进行扩展,包括官方和第三方插件。视觉效果图就是那些需要独立安装的功能之一,如果你使用的是通用渲染管线 (URP)。我们可以使用包管理器来完成这项工作,这是一个专门用于管理官方 Unity 插件的 Unity 窗口。
在安装这些包时需要考虑的是,每个包或插件都有自己的版本,与 Unity 版本无关。这意味着您可以安装 Unity 2022.1,但也可以安装 Visual Effect Graph 13.1.8 或您想要的任何版本,并且实际上可以更新包到新版本而不必升级 Unity。这很重要,因为某些版本的这些包需要 Unity 的最低版本——例如,Visual Effect Graph 13.1.8 需要 Unity 2022.1 作为最低版本。此外,一些包依赖于其他包和这些包的特定版本,因此我们需要确保我们拥有每个包的正确版本以确保兼容性。明确来说,包的依赖项会自动安装,但有时我们可以单独安装它们,因此在这种情况下,我们需要检查所需版本。听起来很复杂,但实际上比听起来简单。
在撰写本书时,为了使视觉效果图正常工作,我们需要版本 13.1.8,并且还需要相同版本的Universal RP。是的,Universal RP 是您可以使用包管理器安装的另一个功能,但因为我们使用 Universal RP 模板创建了项目,所以它已经为我们安装了正确的版本。考虑到这一点,让我们按照以下步骤安装视觉效果图:
- 在 Unity 的顶部菜单中,转到窗口 | 包管理器:

图 11.34:包管理器位置
- 请确保包下拉菜单处于Unity 注册表模式,以查看 Unity 官方包列表:

图 11.35:包管理器 Unity 注册表模式
-
在左侧列中,找到Universal RP并检查右侧是否显示 13.1.8 或更高版本。如果是这样,跳转到步骤 6。不过,请记住,更高版本可能看起来不同,或者使用步骤与本章中显示的不同。
-
如果您没有 13.1.8 或更高版本,点击左侧的指向右的箭头以显示所有可能的安装版本。找到 13.1.8 并点击它。在我的情况下,它显示为当前已安装,因为我已经在项目中安装了该版本,并且没有其他版本可用于 Unity 2022:

图 11.36:包版本选择器
-
在窗口的右下角点击更新到 13.1.8按钮,等待包更新。
-
在窗口的左侧查找视觉效果图包。就像您使用 Universal RP 一样,确保您选择版本 11.0.0 或更高版本:

图 11.37:视觉效果图包
- 点击窗口右下角的安装按钮,等待包安装。有时在安装包后重启 Unity 是推荐的,所以保存您的更改并重启 Unity。
现在我们已经安装了视觉效果图,让我们使用它创建第一个粒子系统。
创建和分析视觉效果图
使用视觉效果图创建粒子系统的方法与常规粒子系统类似。我们将链式配置模块作为粒子行为的一部分,每个模块添加一些特定的行为,但我们的操作方式与 Shuriken 非常不同。首先,我们需要创建一个视觉效果图,这是一个将包含所有模块和配置的资产,然后创建一个将执行图资产以生成粒子的 GameObject。让我们按照以下步骤进行:
- 在项目窗口中,点击+按钮,查找视觉效果 | 视觉效果图:

图 11.38:视觉效果图
- 使用GameObject | 创建空对象选项创建一个空对象:

图 11.39:创建空 GameObject
-
选择创建的物体,并查看检查器。
-
使用添加组件搜索栏,查找视觉效果组件,并点击它以将其添加到对象:

图 11.40:向视觉效果图添加组件
- 将我们创建的视觉效果资产拖动到我们的 GameObject 中视觉效果组件的资产模板属性:

图 11.41:使用先前创建的视觉效果资产进行视觉效果
- 你应该看到从我们的物体中发射出时钟粒子,这是新视觉效果资产中包含的默认行为,意味着它正在正确执行:

图 11.42:默认视觉效果资产结果
现在我们有一个基础效果,让我们创建一些需要大量粒子的东西,比如密集的雨。在这样做之前,我们将探索视觉效果图的一些核心概念。如果您双击视觉效果资产,您将看到以下编辑器:

图 11.43:视觉效果图编辑器窗口
此窗口由几个相互连接的节点组成,生成要执行的动作流。与着色器图一样,您可以通过按住Alt键(Mac 上的Option)并使用鼠标拖动图中的空白区域来导航此窗口。起初,它看起来与着色器图相似,但它的工作方式略有不同,所以让我们研究默认图的每个部分。
首个要探索的区域是包含三个节点的虚线区域。这是 Unity 所说的系统。系统是一组节点,定义了粒子将如何表现,你可以拥有任意多个,这相当于拥有多个粒子系统对象。每个系统由上下文组成,即虚线区域内的节点,在这种情况下,我们有初始化粒子、更新粒子和输出粒子四边形。每个上下文代表粒子系统逻辑流程的不同阶段,因此让我们定义我们图中的每个上下文的作用:
-
初始化粒子:这定义了每个发射粒子的初始数据,例如位置、颜色、速度和大小。它类似于我们在本章开头看到的粒子系统主模块中的启动属性。此节点中的逻辑仅在发射新粒子时执行。
-
更新粒子:在这里,我们可以对活粒子的数据进行修改。我们可以更改粒子数据,如所有帧的当前速度或粒子大小。这类似于 Shuriken 粒子系统的随时间节点。
-
输出粒子四边形:当粒子需要渲染时,将执行此上下文。它将读取粒子数据以确定渲染位置、渲染方式、使用的纹理和颜色以及不同的视觉设置。这类似于先前粒子系统的渲染器模块。
在每个上下文中,除了一些基本配置外,我们还可以添加块。每个块都是在上下文中执行的操作。我们有可以在任何上下文中执行的动作,以及一些特定上下文动作。例如,我们可以在初始化粒子上下文中使用添加位置块来移动初始粒子位置,但如果我们在更新粒子上下文中使用相同的块,它将使粒子持续移动。所以基本上,上下文是粒子生命周期中发生的情况,而块是在这些情况下执行的动作:

图 11.44:初始化粒子上下文中的一个设置速度随机块。这设置了粒子的初始速度
此外,我们还可以有独立上下文,即系统之外的上下文,例如生成。此上下文负责告诉系统需要创建新的粒子。我们可以添加块来指定上下文何时告诉系统创建粒子,例如以固定速率随时间进行、爆发等。其理念是,生成将根据其块创建粒子,而系统则负责根据我们在每个上下文中设置的块初始化、更新和渲染每个粒子。
因此,我们可以看到与 Shuriken 有很多相似之处,但在这里创建系统的方式相当不同。让我们通过创建一个雨效果来加强这一点,这将需要大量的粒子——这是 Visual Effect Graph 的一个很好的用例。
创建雨效果
为了创建此效果,请执行以下操作:
- 将初始化粒子上下文的容量属性设置为
10000:

图 11.45:初始化粒子上下文
- 将Spawn上下文的恒定生成速率的速率设置为
10000:

图 11.46:恒定生成速率块
- 在初始化粒子上下文中的设置速度随机块中,将A和B属性分别设置为
0,-50和0,以及0,-75和0。这将为我们设置一个指向下方的随机速度:

图 11.47:设置速度随机块
-
右键单击初始化粒子标题,并选择创建块。
-
搜索设置位置随机块并点击它:

图 11.48:添加块
-
将设置位置随机块的A和B属性分别设置为
-50,0和-50,50,0和50。这将定义一个初始区域,在该区域内随机生成粒子。 -
点击初始化粒子块中边界属性的左侧箭头以显示其属性,并将中心和大小分别设置为
0,-12.5和0,以及100,25和100。这将定义粒子应可见的区域。粒子实际上可以移动到这个区域之外,但只渲染我们感兴趣它们可见的区域是很重要的。
在互联网上搜索视锥剔除以获取有关边界的更多信息。

图 11.49:配置块
- 选择执行系统的 GameObject,在场景视图的右下角窗口中检查显示边界复选框以查看之前定义的边界:

图 11.50:视觉效果播放控制
- 如果看不到右下角的窗口,请点击屏幕左上角的VE(视觉效果)按钮以显示它。此按钮仅在您在层次结构中选择了雨视觉效果 GameObject 时才会显示:

图 11.51:另一种显示视觉效果播放控制的方式
- 如果看不到应用的变化,请点击窗口左上角的编译按钮,它看起来像箭头下方的纸篓。此外,您可以使用Ctrl + S(在 Mac 上为Command + S)保存您的更改:

图 11.52:VFX 资产保存控制
- 将对象位置设置为覆盖整个底部区域。在我的例子中,位置是
100、37和100。记住,你需要更改变换组件的位置来完成这个操作:

图 11.53:设置变换位置
- 将初始化粒子中设置寿命随机块的A和B属性设置为
0.5。这将使粒子寿命更短,确保它们始终在边界内:

图 11.54:设置寿命随机块
- 将输出粒子四边形上下文的主纹理属性更改为另一个纹理。在这种情况下,之前下载的烟雾纹理可以在这里使用,即使它不是水,因为我们将在稍后修改其外观。你也可以尝试下载一个水滴纹理,如果你想的话:

图 11.55:VFX 图主纹理
- 将输出粒子四边形上下文的混合模式设置为添加:

图 11.56:VFX 图的添加模式
- 我们需要稍微拉伸我们的粒子,使其看起来像真正的雨滴而不是下落的球体。在完成这个操作之前,首先我们需要改变我们粒子的方向,这样它们就不会总是指向摄像机。为了做到这一点,在输出粒子四边形上下文中的定向块上右键单击并选择删除(或在 PC 上按 Delete 或在 Mac 上按 Command + Backspace):

图 11.57:删除一个块
-
我们希望根据粒子的速度方向拉伸粒子。在实际上进行这一操作之前,另一个准备步骤是选择输出粒子四边形上下文的标题并按空格键以查找要添加的块。在这种情况下,我们需要搜索并添加沿速度定向块。
-
将一个设置比例块添加到初始化粒子上下文,并将比例属性设置为
0.25、1.5和0.25。这将使粒子拉伸,看起来像下落的雨滴:

图 11.58:设置比例块
- 再次单击左上角的编译按钮以查看更改。你的系统应该看起来像这样:

图 11.59:雨效果
我们刚刚修改了视觉效果图的许多不同属性,但如果你想要两个相同视觉效果图的实例,但略有不同,我建议你查看黑板功能,这将允许你在检查器中公开属性。例如,你可以在另一个场景中制作密度较低的雨,降低生成率,或者更改粒子颜色以制作酸雨,所有这些都可以使用相同的图,但现在让我们保持简单。
黑板功能也存在于着色器图中。
从这里,您可以按照自己的意愿添加和删除上下文中的块,并且再次,我建议您查找已经创建的视觉效果图来获取其他系统的灵感。实际上,您可以通过查看 Shuriken 中制作的效果和使用类似块来获得视觉效果图的灵感。此外,我建议您在网上或以下链接中搜索视觉效果图文档:docs.unity3d.com/Packages/com.unity.visualeffectgraph@13.1/manual/index.html,以了解更多关于这个系统。您还可以在包管理器中选择包时,通过点击查看文档按钮访问任何 Unity 包的文档。

图 11.60:包管理器文档链接
现在我们已经学会了如何创建不同的视觉效果,让我们看看如何通过脚本使用它们来实现对游戏中发生的事情做出反应的效果。
脚本化视觉效果
视觉反馈是使用不同的 VFX(如粒子和一个 VFX 图)来加强正在发生的事情的概念。例如,假设我们现在正在射击我们的武器,我们知道这是在发生,因为我们可以看到子弹。然而,这并不像真正的射击效果,因为一个合适的射击效果应该在枪口处有一个枪口效果。另一个例子是敌人死亡——它只是没有动画地消失!这并不像它本可以那样令人满意。我们可以添加一个小爆炸(考虑到它们是机器人)。
让我们通过以下步骤开始让敌人死亡时产生爆炸:
-
创建一个爆炸效果或从资产商店下载一个。它不应该循环,并且在爆炸结束后需要自动销毁(确保循环未勾选,并且在主模块中将停止动作设置为
销毁)。 -
资产商店中的一些爆炸可能使用与 URP 不兼容的着色器。您可以通过使用窗口 | 渲染 | 渲染管线转换器来修复它们,正如我们在第四章中看到的,导入和集成资产。
-
手动升级那些没有自动升级的材料。
-
向
EnemyPrefab 添加一个名为ExplosionOnDeath的脚本。这将负责在敌人死亡时生成粒子 Prefab。 -
添加一个名为
particlePrefab的GameObject类型字段,并将爆炸 Prefab 拖放到它上面。
您可能期望将爆炸生成添加到Life组件中。在这种情况下,您假设与生命相关的一切在死亡时都会产生粒子,但考虑一下这样的场景:角色在死亡时带有下落动画,或者可能是一个没有任何效果就消失的对象。如果某些行为在大多数场景中都没有使用,那么最好在单独的可选脚本中编码它,这样我们可以混合和匹配不同的组件,以获得我们想要的确切行为。
-
让脚本访问
Life组件并订阅其onDeath事件。 -
在
listener函数中,在相同的位置生成粒子系统:

图 11.61:爆炸生成器脚本
Visual Scripting 版本看起来是这样的:

图 11.62:爆炸生成器视觉脚本
如您所见,我们只是在之前章节中学到的相同概念的基础上,以新的方式组合它们。这就是编程的全部内容。
让我们继续讨论枪口效果,它也将是一个粒子系统,但这次我们将采取另一种方法:
-
如果你还没有,请从 Asset Store 下载一个武器模型。我们书中使用的包中的角色已经自带了一个,所以我们将使用那个。
-
如果你的角色中还没有,请实例化武器,使其成为玩家手的父级。记住,我们的角色是绑定的,有一个手骨,所以你应该把武器放在那里。
-
在本书中下载的角色所带的武器是一个特殊场景,其中武器有一个SkinnedMeshRenderer。这个组件使用我们在第十七章“使用 Animator、Cinemachine 和 Timeline 创建动画”中将要学习的Skinning Animation系统。在这种情况下,武器的移动将受到我们将在那一章中使用的动画的影响,所以现在让我们保持武器现在的位置,即使它看起来有点奇怪。
-
创建或获取一个枪口粒子系统。在这种情况下,我的枪口粒子系统被创建为一个短粒子系统,它爆发出一群粒子然后自动停止。尽量获取一个具有这种行为的东西,因为还有其他一些会循环,处理这种情况的脚本将不同。
-
在编辑器中创建粒子系统预制件的实例,并将其放置在武器内部,位于武器前方,与枪管对齐。确保粒子系统主模块的Play On Awake属性未勾选;我们不希望在按下射击键之前发射枪口:

图 11.63:与武器关联的枪口效果
-
在
PlayerShooting中创建一个名为muzzleEffect的ParticleSystem类型的字段。 -
将枪口效果的 GameObject 拖到检查器中,它已经在枪上作为父级。现在,我们有了对枪口
ParticleSystem组件的引用,可以管理它。 -
在检查我们是否在射击的
if语句中,执行muzzleEffect.Play();来播放粒子系统。它将自动停止,并且足够短,可以在按键之间完成:

图 11.64:与武器关联的枪口效果
Visual Scripting 版本的附加节点和变量如下:

图 11.65:开火脚本的视觉脚本
最后,我们需要在射击时通过以下方式在 AI 上也播放开火效果:
-
就像我们对
PlayerShooting做的那样,在EnemyFSM中创建一个名为muzzleEffect的ParticleSystem类型的字段。 -
在
Shoot方法内部,在方法末尾添加muzzleEffect.Play();行以播放粒子系统:

图 11.66:开火脚本的 C# 脚本
Visual Scripting 版本为 Attack State 和 Attack Base 添加的附加节点如下:

图 11.67:攻击状态的开火脚本
记得将这些节点添加到攻击状态中,并将 muzzleEffect 变量添加到 AI 变量组件中。
摘要
在本章中,我们讨论了创建粒子系统的两种不同方法:使用 Shuriken 和 VFX Graph。我们使用它们来模拟不同的流体现象,如火焰、瀑布、烟雾和雨。想法是将粒子系统与网格结合以生成场景所需的全部可能道具。此外,正如你可以想象的那样,创建这类效果需要专业水平,这要求你深入了解。如果你想致力于此(技术艺术家工作的另一部分),你需要学习如何创建自己的粒子纹理以获得你想要的确切外观和感觉,编写控制系统某些方面的脚本,以及粒子创建的几个其他方面。然而,这些都超出了本书的范围。
现在我们场景中有了一些雨,我们可以看到天空和场景中的光照并不真正反映雨天,所以让我们在下一章中修复这个问题!
第十二章:使用通用渲染管线进行照明
照明是一个复杂的话题,处理它有几种可能的方法,每种方法都有其优缺点。为了获得最佳的质量和性能,你需要确切地知道你的渲染器如何处理照明,这正是我们将在本章中要做的。我们将讨论在 Unity 的通用渲染管线(URP)中如何处理照明,以及如何正确配置它以适应场景的氛围,并使用适当的照明效果。
在本章中,我们将检查以下照明概念:
-
应用照明
-
应用阴影
-
优化照明
在本章结束时,我们将正确使用不同的 Unity 照明系统,如直接光照和光照贴图,以反映多云和雨夜的景象。
应用照明
当讨论在游戏中处理照明的方法时,有两种主要的方法,称为前向渲染和延迟渲染。它们以不同的顺序、不同的技术、不同的要求、优点和缺点来处理照明。前向渲染通常推荐用于性能,而延迟渲染通常推荐用于质量。后者是 Unity 的高清渲染管线(HDRP)所使用的,用于高端设备中的高质量图形。
在撰写本书时,Unity 正在为 URP 开发一个性能版本。此外,在 Unity 中,前向渲染器有两种模式:多遍历前向,用于内置渲染器(旧的 Unity 渲染器),以及单遍历前向,用于 URP。同样,两者都有其优缺点。
选择哪种方法取决于你正在创建的游戏类型以及你需要将游戏运行在哪个平台上。你选择的选项将因你如何将照明应用于场景而大量变化,因此了解你正在处理哪个系统至关重要。
在下一节中,我们将讨论以下实时照明概念:
-
讨论照明方法
-
使用天空盒配置环境照明
-
在 URP 中配置照明
让我们从比较之前提到的照明方法开始。
讨论照明方法
回顾一下,我们在本章开头提到了三种处理照明的最主要方式:
-
前向渲染(单次遍历)
-
前向渲染(多遍历)
-
延迟渲染
在我们查看每种方法之间的差异之前,让我们谈谈它们共有的特点。这三个渲染器开始绘制场景,通过确定哪些物体可以被相机看到——即那些落在相机视锥体内的物体,并提供一个当你选择相机时可以看到的巨大金字塔:

图 12.1:显示仅由相机看到的物体的相机视锥体
之后,Unity 将按照从相机最近到最远的顺序排列它们(透明对象的处理方式略有不同,但在此我们先忽略这一点)。这样做的原因是因为靠近相机的对象更有可能覆盖大部分相机视野,因此它们会遮挡其他对象(会阻止其他对象被看到),从而防止我们浪费资源计算被遮挡对象的像素。
最后,Unity 将按照这个顺序尝试渲染这些对象。这就是不同光照方法开始出现差异的地方,因此让我们开始比较两种前向渲染变体。对于每个对象,单次遍历前向渲染将在一次操作中计算对象的外观,包括所有影响该对象的光源,或者我们称之为绘制调用。
绘制调用是 Unity 要求显卡实际渲染指定对象的精确时刻。所有之前的工作都是为了这一刻做准备。在多遍历前向渲染器的情况下,通过稍微简化实际逻辑,Unity 将针对影响该对象的光源渲染该对象一次;因此,如果对象被三个光源照亮,Unity 将渲染该对象三次,这意味着将发出三个绘制调用,并且将向 GPU 发出三个调用以执行渲染过程:

图 12.2:左图,多遍历中受两个光源影响球体的第一次绘制调用;中图,球体的第二次绘制调用;右图,两次绘制调用的组合
现在你可能正在想,“为什么我要使用多遍历?单遍历性能更好!”是的,你是对的!单遍历比多遍历性能更好,这意味着我们的游戏将以更高的帧率运行,但是有一个大问题。GPU 中的绘制调用有一个可以执行的操作数量限制,因此绘制调用的复杂度有限。计算对象的外观以及所有影响它的光源非常复杂,为了使其适应仅一次绘制调用,单遍历执行了光照计算的简化版本,这意味着光照质量较低,功能较少。它们还对一次可以处理的光源数量有限制,截至本书编写时,每个对象为八个,尽管你可以根据需要配置更少,但默认值对我们来说已经足够好了。这听起来像是一个小数字,但通常已经足够了。
另一方面,多遍历可以应用你想要的任意数量的灯光,并且可以为每个灯光执行不同的逻辑。假设我们的对象有四个影响它的灯光,但有两个灯光影响它非常严重,因为它们更近或强度更高,而其余的只是足够明显地影响对象。在这种情况下,我们可以用高质量渲染前两个灯光,而用低价计算渲染剩余的灯光——没有人会注意到差异。
在这种情况下,多遍历可以使用像素光照计算前两个灯光,其余的则使用顶点光照。区别在于它们的名称;像素是按对象像素计算光照,而顶点是按对象顶点计算光照,并填充这些顶点之间的像素,从而在顶点之间插值信息。你可以在以下图像中清楚地看到差异:

图 12.3:左图,使用顶点光照渲染的球体;右图,使用像素光照渲染的球体
在单次遍历中,在一个绘制调用中计算所有内容迫使你必须使用顶点光照或像素光照;你不能将它们结合起来。
因此,为了总结单次遍历和多遍历之间的区别,在单次遍历中,由于每个对象只绘制一次,所以性能更好,但你受到可应用灯光数量的限制,而在多遍历中,你需要渲染对象多次,但灯光数量没有限制,你可以为每个灯光指定你想要的确切质量。还有其他一些事情需要考虑,比如绘制调用的实际成本(一个绘制调用可能比两个简单的调用更昂贵),以及像卡通着色这样的特殊光照效果,但让我们保持简单。
最后,让我们简要讨论一下延迟渲染。即使我们不会使用它,了解为什么我们不这样做也是很有趣的。在确定哪些对象位于视锥体内并对它们进行排序后,延迟渲染将不进行任何光照渲染对象,生成所谓的G-Buffer。G-Buffer 是一组包含有关场景中对象不同信息的图像,例如像素的颜色(无光照)、每个像素的方向(称为法线)以及像素与摄像机的距离。
你可以在以下图像中看到 G-Buffer 的典型示例:

图 12.4:左图,对象的单调颜色;中图,每个像素的深度;右图,像素的法线
法线是方向,方向的x、y和z分量编码在颜色的 RGB 分量中。
在渲染场景中的所有对象之后,Unity 将遍历相机中可以看到的所有光源,从而在 G-Buffer 上应用一层光照,并从中获取信息来计算特定的光照。处理完所有光源后,你会得到以下结果:

图 12.5:前一张图片中应用于 G-Buffer 的三个光源的组合
正如你所见,这种方法中的“延迟”部分来源于将光照计算作为渲染过程的最后阶段的想法。这样做更好,因为你不会浪费资源去计算那些可能被遮挡的对象的光照。如果在前向模式下首先渲染图像的地面,那么其他对象将要遮挡的像素就会被无用地计算。此外,延迟渲染只计算光线能够到达的确切像素的光照。例如,如果你使用手电筒,Unity 只会计算手电筒锥形范围内的像素的光照。这里的缺点是延迟渲染不支持一些相对较旧的显卡,并且你不能使用顶点光照质量进行光照计算,因此你需要付出像素光照的代价,这在低端设备上(甚至在简单的图形游戏中)是不推荐的。
那么,我们为什么使用单次遍历前向渲染(URP)呢?因为它在性能、质量和简单性之间提供了最佳平衡。在这个游戏中,我们不会使用太多光源,所以不会担心单次遍历的光源数量限制。如果你需要更多光源,可以使用延迟渲染,但请考虑额外的硬件要求以及没有顶点光照选项的性能成本。现在我们已经对 URP 如何处理光照有了非常基本的了解,让我们开始使用它吧!
使用天空盒配置环境光照
有不同的光源可以影响场景,例如太阳、手电筒、灯泡等等。这些被称为直接光源——即发出光线的对象。然后,我们有间接光源,它表示直接光源如何在其他对象上反射,比如墙壁。然而,计算所有光源发出的所有光线的所有反射是非常昂贵的,从性能角度来看,并且需要支持光线追踪的特殊硬件。问题是,如果没有间接光源,将会产生不真实的结果,你可以观察到阳光无法到达的地方会变得完全黑暗,因为没有来自光线击中其他地方的光线反射。
在下一张图片中,你可以看到一个错误配置的场景中可能的样子:

图 12.6:没有环境光照的山上投射的阴影
如果您遇到这个问题,解决它的有效方法是使用那些反弹的近似值。这些就是我们所说的环境光。这代表了一层基础光照,通常根据天空的颜色应用一点光,但您可以选择任何您想要的颜色。例如,在晴朗的夜晚,我们可以选择深蓝色来代表月光带来的色调。
如果您在 Unity 2022 中创建新场景,通常这是自动完成的,但在没有自动完成或场景是通过其他方法创建的情况下,了解如何通过以下方式手动触发此过程是有用的:
- 点击窗口 | 渲染 | 光照。这将打开场景光照设置窗口:

图 12.7:光照设置位置
- 点击窗口底部的生成光照按钮。如果您到目前为止还没有保存场景,将弹出一个提示要求您保存它,这是必要的:

图 12.8:生成光照按钮
- 查看 Unity 窗口的右下角,检查进度计算条,以查看何时完成进程:

图 12.9:光照生成进度条
- 现在,您可以看到完全黑暗的区域现在被天空发出的光照亮:

图 12.10:带有环境光照的阴影
现在,通过这样做,我们有了更好的光照,但它仍然看起来像晴天。记住,我们想要的是雨天。为了做到这一点,我们需要更改默认的天空,使其多云。您可以通过下载天空盒来实现这一点。您现在在场景周围看到的当前天空只是一个包含每个面的纹理的大立方体,这些纹理具有特殊的投影,以防止我们检测到立方体的边缘。我们可以为立方体的每个面下载六张图片,并将它们应用到您想要的任何天空,所以让我们这样做:
-
您可以从任何地方下载天空盒纹理,但在这里,我将选择资产商店。通过前往窗口 | 资产商店并访问资产商店网站来打开它。
-
在右侧的分类列表中查找分类 | 2D | 纹理与材质 | 天空。记住,如果您看不到分类列表,需要将窗口变宽:

图 12.11:纹理与材质
-
记得在价格选项中勾选免费资源复选框。
-
选择您喜欢的雨天天空盒。请注意,天空盒有不同的格式。我们使用的是六图像格式,所以在下载之前请检查这一点。还有一种格式叫做立方体贴图,它与六图像格式基本相同,但我们将继续使用六图像格式,因为它是最简单且易于使用和修改的。在我的情况下,我选择了图 12.12中显示的天空盒包。下载并导入它,就像我们在第五章,C#和视觉脚本简介中做的那样:

图 12.12:为此书选择的选定的天空盒
-
通过在项目窗口中使用+图标并选择材质来创建一个新的材质。
-
将该材质的着色器选项设置为天空盒/6 面。记住,天空盒只是一个立方体,因此我们可以应用材质来改变其外观。天空盒着色器已准备好应用六个纹理。
-
将六个纹理拖动到材质的前、后、左、右、上和下属性。六个下载的纹理将具有描述性的名称,以便您知道哪些纹理放在哪里:

图 12.13:天空盒材质设置
-
将材质直接拖动到场景视图中的天空。确保您不要将材质拖动到对象上,因为材质将被应用到它上面。
-
重复环境光计算步骤的 1 到 4 步(光照设置 | 生成光照)以根据新的天空盒重新计算它。在以下图像中,您可以看到我到目前为止的项目结果:

图 12.14:应用的天空盒
现在我们有了良好的基础光照层,我们可以开始添加灯光对象。
在 URP 中配置光照
我们可以在场景中添加三种主要的直接灯光类型:
- 方向性灯光:这是一种代表太阳的灯光。该对象以它面对的方向发射光线,而不管它的位置如何;太阳向右移动 100 米不会产生太大影响。例如,如果您慢慢旋转这个对象,您可以生成昼夜循环:

图 12.15:方向性灯光结果
- 点光源:这种灯光代表一个灯泡,以全向方式发射光线。与方向性灯光相比,它的位置很重要,因为它更接近我们的物体。此外,由于它是一种较弱的灯光,其强度会根据距离变化,因此其效果有一个范围——物体离灯光越远,接收到的强度越弱:

图 12.16:点光源结果
-
聚光灯:这种灯光代表一个光锥,例如手电筒发出的光。它在位置很重要且光强度随距离衰减方面与点光源类似。但在这里,它指向的方向(因此其旋转)也很重要,因为它将指定光线投射的位置:
![图片]()
图 12.17:聚光灯结果
到目前为止,我们已经有了很好的雨天环境光照,但场景中唯一的直接灯光——方向性灯光,看起来不会是这样,所以让我们改变一下:
-
在层次窗口中选择方向性灯光对象,然后查看检查器窗口。
-
点击发射部分的颜色属性以打开颜色选择器。
-
选择深灰色以实现部分被云层遮挡的太阳光线。
-
将阴影类型设置为无阴影。现在我们有了多云的一天,太阳不会投射出清晰的阴影,但我们稍后会更多地讨论阴影:

图 12.18:无阴影的柔和方向光
现在场景变暗了,我们可以添加一些灯光来照亮场景,如下所示:
-
通过GameObject | Light | Spotlight创建一个聚光灯。
-
选择它。然后,在检查器窗口中,将形状部分的内/输出聚光灯角度设置为 90 和 120,这将增加锥体的角度。
-
在发射部分将范围设置为
50,这意味着灯光可以延伸到 50 米,并在途中衰减。 -
在发射部分将强度设置为
1000:

图 12.19:聚光灯设置
- 将灯光放置在游戏基地的一个角落,指向中心:

图 12.20:聚光灯位置
-
通过选择它并按Ctrl+D(在 Mac 上为Command+D)来复制该灯光。
-
将其放置在底座的对面角落:

图 12.21:两个聚光灯结果
你可以继续向场景中添加灯光,但要注意不要走得太远——记住灯光的限制。此外,你可以在灯光所在的位置下载一些灯光柱来从视觉上证明灯光的来源。现在我们已经实现了适当的照明,我们可以谈谈阴影了。
应用阴影
你可能认为场景中已经有了阴影,但实际上并没有。物体的较暗区域,即不面向灯光的区域,没有阴影——它们没有被照亮,这与阴影有很大的不同。在这种情况下,我们指的是从一个物体投射到另一个物体的阴影——例如,玩家投射到地板上的阴影,或者从山脉投射到其他物体上。阴影可以增加场景的质量,但它们在计算上也很昂贵,因此我们有两种选择:不使用阴影(推荐用于低端设备,如手机)或根据我们的游戏和目标设备在性能和质量之间找到平衡。
在本节中,我们将讨论以下关于阴影的主题:
-
理解阴影计算
-
配置高性能阴影
让我们先讨论 Unity 如何计算阴影。
理解阴影计算
在游戏开发中,众所周知,阴影在性能方面是昂贵的,但为什么呢?当一个光束在到达目标物体之前击中另一个物体时,就会产生一个阴影。在这种情况下,该像素点不会从该光源获得任何光照。这里的问题与我们在环境光照模拟中所遇到的问题相同——计算所有可能的光束及其碰撞将过于昂贵。因此,我们再次需要近似,这就是阴影贴图发挥作用的地方。
阴影图是从光源视角渲染的图像,但它不会绘制带有所有颜色和光照计算的完整场景,而是将所有对象以灰度形式渲染,其中黑色表示像素非常远离相机,而更白则表示像素更靠近相机。如果你这么想,每个像素都包含了关于光线射线击中位置的信息。通过知道光源的位置和方向,你可以使用阴影图计算出每个“射线”击中的位置。
在以下图像中,你可以看到我们方向光的阴影图:

图 12.22:由场景中的方向光生成的阴影图
不同的光照类型计算阴影图的方式略有不同,尤其是点光源。由于它是全方向的,它需要从所有方向(前、后、左、右、上、下)渲染场景多次,以便收集所有射出的光线信息。不过,我们在这里不会详细讨论这个问题,因为我们可以整天都在讨论它。
现在,这里需要强调的一个重要问题是,阴影图是纹理,因此它们具有分辨率。分辨率越高,我们的阴影图计算的“射线”就越多。你可能想知道低分辨率阴影图在只有少数射线时看起来是什么样子。请看以下图像以查看一个例子:

图 12.23:使用低分辨率阴影图渲染的硬阴影
这里的问题是,射线越少,阴影像素就越大,导致阴影出现像素化。在这里,我们首先需要考虑的配置是:阴影的理想分辨率是多少?你可能会想直接增加分辨率,直到阴影看起来平滑,但当然,这会增加计算所需的时间,因此会显著影响性能,除非你的目标平台能够处理它(移动设备肯定不能)。在这里,我们可以使用软阴影技巧,即在阴影上应用模糊效果以隐藏像素化的边缘,如图下所示:

图 12.24:使用低分辨率阴影图渲染的软阴影
当然,模糊效果不是免费的,但如果你接受其模糊的结果,将其与低分辨率阴影图结合,可以在质量和性能之间产生良好的平衡。
现在,低分辨率阴影图还有一个问题,称为阴影痤疮。这是你在以下图像中可以看到的光照错误:

图 12.25:由低分辨率阴影图产生的阴影痤疮
低分辨率的阴影图产生误报,因为它计算的“射线”较少。需要在射线之间着色的像素需要从最近的像素中插值信息。阴影图的分辨率越低,射线之间的间隙就越大,这意味着精度更低,误报更多。一个解决方案是提高分辨率,但同样,也会有性能问题(就像总是那样)。我们有一些巧妙的解决方案,比如使用深度偏差。以下图像就是一个例子:

图 12.26:两个远“射线”之间的误报。高亮区域认为射线在到达之前已经击中了物体。
深度偏差的概念很简单——简单到似乎是一种很大的作弊,实际上也是,但游戏开发充满了这样的东西!为了防止误报,我们“推动”射线稍微远一点,刚好足够使插值射线达到被照亮的表面:

图 12.27:具有深度偏差的射线以消除误报
当然,正如你可能预料的那样,他们没有轻易解决这个问题,而是有一个警告。推动深度会在其他区域产生误报,如下面的图像所示。看起来立方体在空中漂浮,但实际上,它是在接触地面——误报产生了它漂浮的错觉:

图 12.28:由于高深度偏差产生的误报
当然,我们有一种对抗这种情况的技巧,称为法线偏差。它将物体的网格沿着它们面对的方向推动,而不是射线。这个有点棘手,所以我们不会在这里详细介绍,但想法是结合一点深度偏差和一点法线偏差可以减少误报,但不会完全消除它们。因此,我们需要学会如何与之共存,并通过巧妙地定位物体来隐藏这些阴影差异:

图 12.29:减少了误报,这是结合深度和法线偏差的结果
影响阴影图工作方式的其他几个方面中,有一个是光程。光程越小,阴影覆盖的区域就越小。相同的阴影图分辨率可以增加该区域的更多细节,所以尽量减少光程,就像我们将在下一节中做的那样。
我现在能想象你的表情,是的,光照很复杂,我们只是刚刚触及表面!但保持你的士气!经过一点试验和错误的设置调整后,你会更好地理解它。我们将在下一节中这样做。
如果你真的对了解阴影系统的内部结构感兴趣,我建议你看看阴影级联的概念,这是一个关于方向光和阴影图生成的先进主题。
配置高性能阴影
由于我们针对的是中端设备,我们将尝试在质量和性能之间取得良好的平衡,所以让我们开始只为聚光灯启用阴影。方向光的阴影不会那么明显,实际上,雨天不会生成清晰的阴影,所以我们将以此为借口不计算这些阴影。为了做到这一点,请执行以下操作:
- 在层次结构中按住Ctrl(Mac 上的Command)的同时点击两个聚光灯。这将确保在检查器窗口中进行的任何更改都将应用于两者:

图 12.30:选择多个对象
- 在检查器窗口中,在阴影部分将阴影类型设置为软阴影。在这里我们将使用低分辨率阴影贴图,软模式可以帮助隐藏像素化的分辨率:

图 12.31:软阴影设置
- 选择方向光,并将阴影类型设置为无阴影以防止其投射阴影:

图 12.32:无阴影设置
- 创建一个立方体(GameObject | 3D Object | Cube),并将其放置在灯光附近,以便在测试时有一个可以投射阴影的对象。
现在我们已经有一个基本的测试场景,让我们调整阴影贴图的分辨率设置,以防止阴影噪点:
-
前往编辑 | 项目设置。
-
在左侧列表中,查找图形并点击它:

图 12.33:图形设置
- 在选择此选项后出现的属性中,点击可脚本渲染管线设置下面的框——包含一个名称的那个。在我的情况下,这是URP-HighFidelity,但如果你有不同版本的 Unity,它可能不同:

图 12.34:当前渲染管线设置
- 这样做将在项目窗口中突出显示一个资产,所以在选择它之前请确保窗口是可见的。选择突出显示的资产:

图 12.35:当前管线高亮显示
- 此资产有几个与 URP 如何处理其渲染相关的图形设置,包括照明和阴影。展开照明部分以显示其设置:

图 12.36:管线照明设置
-
在附加灯光子部分下的阴影分辨率设置表示所有非方向光的阴影贴图分辨率(因为它是主光)。如果它还没有设置为
1024,请将其设置为1024。 -
在阴影部分下,你可以看到深度和法线偏差设置,但那些将影响所有灯光。即使现在我们的方向光没有阴影,我们只想影响额外的灯光偏差值,因为它们的图集分辨率与主图(方向光)不同,所以,选择聚光灯并将偏差设置为自定义,将深度和法线偏差设置为
0.25,以便在我们移除阴影痤疮之前尽可能减少它们:

图 12.37:偏差设置
-
这并不完全与阴影相关,但在通用 RP 设置资产中,你可以更改每个对象的灯光限制来增加或减少可以影响对象的光的数量(不超过八个)。目前,默认设置就很好。
-
如果你之前遵循了前面提到的阴影级联提示,你可以稍微调整一下级联值,以启用方向光阴影并观察效果。记住,那些阴影设置仅适用于方向光。
-
在方向光中没有阴影,但在任何其他情况下,考虑在阴影部分中减少最大距离值,这将影响方向光阴影的范围。
-
在层次结构中选择两个灯光,并将它们的范围设置为 40 米。看看在这次更改前后阴影的质量如何改善。
记住,那些值只适用于我的情况,所以试着调整一下值,看看它如何改变结果——你可能会为你的场景找到一个更好的设置,如果它与我的设计不同的话。此外,记住没有阴影始终是一个选项,所以如果你的游戏每秒帧数较低,也就是 FPS(且没有其他性能问题),也请考虑这一点。
你可能认为这就是我们在光照方面能做的所有关于性能的事情了,但幸运的是,情况并非如此!我们还有另一个资源可以利用来进一步改进它,这就是静态光照。
优化光照
我们之前提到不计算光照对性能有好处,但如果不计算灯光,却仍然有灯光呢?是的,听起来好得令人难以置信,但实际上是可能的(当然,也很棘手)。我们可以使用一种称为静态光照或烘焙的技术,它允许我们一次性计算光照并使用缓存的成果。
在本节中,我们将介绍与静态光照相关的以下概念:
-
理解静态光照
-
烘焙光照贴图
-
将静态光照应用于动态对象
理解静态光照
这个想法很简单:只需进行一次光照计算,保存结果,然后使用这些结果而不是一直计算光照。
你可能想知道为什么这不是默认的技术。这是因为它有一些限制,其中最大的限制是动态对象。预先计算阴影意味着一旦计算完成,它们就无法改变,但如果一个投射阴影的对象被移动,阴影仍然会存在,所以这里需要考虑的主要问题是你不能使用这种技术来处理移动对象。相反,你需要为静态对象混合静态或烘焙光照,为动态(移动)对象使用实时光照。此外,考虑到这种技术仅适用于静态对象,它也仅适用于静态光源。再次强调,如果光源移动,预先计算的数据就变得无效了。
另一个你需要考虑的限制是预先计算的数据可能会对内存产生巨大影响。这些数据占据了 RAM 中的空间,可能达到数百 MB,因此你需要考虑你的目标平台是否有足够的空间。当然,你可以降低预先计算的照明质量以减小该数据的大小,但你需要考虑这种质量损失是否过多地影响了你游戏的外观和感觉。与所有关于优化的选项一样,你需要平衡两个因素:性能和质量。
在我们的流程中,我们有几种预先计算的数据,但最重要的一个是被称为光照贴图的数据。光照贴图是一种包含场景中所有对象的所有阴影和光照的纹理,因此当 Unity 应用预先计算或烘焙的数据时,它会查看这个纹理以确定静态对象的哪些部分被照亮,哪些没有被照亮。
你可以在以下图像中看到一个光照贴图的例子:

图 12.38:左,没有光照的场景;中,包含该场景预先计算数据的光照贴图;右,光照贴图应用于场景
拥有光照贴图有其自身的优势。烘焙过程在 Unity 中进行,在游戏发布给用户之前,这样你可以花费大量时间计算在运行时无法完成的事情,比如提高精度、光线反弹、角落的光线遮挡以及发射物体的光线。然而,这也可能成为一个问题。记住,动态对象仍然需要依赖于实时光照,而且与静态光照相比,这种光照看起来会非常不同,因此我们需要对其进行大量调整,以便用户不会注意到差异。
既然我们已经对静态光照有了基本的概念,让我们深入了解如何使用它。
烘焙光照贴图
要使用光照贴图,我们需要对 3D 模型做一些准备工作。记住,网格有UV,它包含有关纹理的哪些部分需要应用到模型各部分的信息。有时,为了节省纹理内存,您可以将同一块纹理应用到不同的部分。例如,在汽车的纹理中,您不会看到四个轮子;只有一个轮子,您可以将相同的纹理块应用到所有轮子上。这里的问题是,静态光照以相同的方式使用纹理,但在这里,它将应用光照贴图来照亮对象。在轮子的情况下,问题会是一个轮子接收到阴影,所有轮子都会有,因为所有轮子都在共享相同的纹理空间。通常的解决方案是在模型中有一组没有共享纹理空间的 UV,专门用于光照贴图。
有时,下载的模型已经为光照贴图做好了准备,有时则没有,但幸运的是,Unity 在这些情况下为我们提供了支持。为了确保模型能够正确计算光照贴图,让我们通过以下步骤让 Unity 自动生成光照贴图 UV:
-
在项目窗口中选择网格资产(FBX)。
-
在模型选项卡中,找到底部的生成光照贴图 UV复选框并勾选它。
-
在底部点击应用按钮:

图 12.39:生成光照贴图设置
- 对每个模型重复此过程。技术上,您只能在烘焙光照贴图后出现伪影和奇怪结果的模型中这样做,但为了以防万一,我们现在就在所有模型中这样做。
在为光照贴图准备模型之后,下一步是告诉 Unity 哪些对象不会移动。为此,请执行以下操作:
-
选择不会移动的对象。
-
在检查器窗口的右上角勾选静态复选框:

图 12.40:静态复选框
-
对每个静态对象重复此操作(对于灯光来说这不是必要的;我们稍后会处理这些)。
-
您还可以选择多个对象的容器,勾选静态复选框,并在提示中点击是,所有子对象按钮,将复选框应用到所有子对象。
考虑到你可能不希望每个对象,即使它是静态的,都进行光照贴图,因为光照贴图的对象越多,你需要的纹理大小就越大。例如,地形可能太大,会消耗大部分光照贴图的大小。通常,这是必要的,但在这个案例中,聚光灯几乎不接触地形。在这里,我们有两个选择:将地形保留为动态,或者更好的是,直接告诉聚光灯不要影响地形,因为其中一个只受到环境光照和方向光(不会产生阴影)的照射。记住,我们之所以能这样做,是因为我们的场景类型;然而,在其他场景中,你可能需要使用其他设置。你可以通过以下方式从实时和静态光照计算中排除一个对象:
-
选择要排除的对象。
-
在检查器窗口中,点击图层下拉菜单,然后点击添加图层…:

图 12.41:创建图层按钮
- 在这里,你可以创建一个图层,这是一个用于识别哪些对象不会受到光照影响的对象组。在图层列表中,寻找一个空白区域,为这类对象输入任何名称。以我的情况为例,我只将地形排除在外,所以我将其命名为地形:

图 12.42:图层列表
- 再次选择地形,转到图层下拉菜单,并选择上一步创建的图层。这样,你可以指定这个对象属于那个对象组:

图 12.43:更改 GameObject 的图层
- 选择所有聚光灯,在检查器窗口的渲染部分查找剔除遮罩,点击它,取消勾选之前创建的图层。这样,你可以指定这些灯光不会影响那个对象组:

图 12.44:光照剔除遮罩
- 现在,你可以看到那些选定的灯光没有照亮或向地形应用阴影。
现在,是时候处理灯光了,因为静态复选框对它们不起作用。对于它们,我们有以下三种模式:
-
实时:在实时模式下,灯光将影响所有对象,无论是静态的还是动态的,使用实时光照,这意味着没有预先计算。这对于不静止的灯光很有用,例如玩家的手电筒、因风而移动的灯等。
-
烘焙:与实时相反,这种灯光只会影响具有光照贴图的静态对象。这意味着如果玩家(动态)在街道上的烘焙灯光(静态)下移动,街道看起来会被照亮,但玩家仍然保持黑暗,不会在街道上产生任何阴影。这种想法是用于不会影响任何动态对象的灯光,或者用于几乎不影响它们的灯光,这样我们就可以通过不计算它们来提高性能。
-
混合:如果你不确定使用哪个模式,这是首选模式。这种光照将计算静态对象的光照贴图,但也会影响动态对象,结合其实时光照和烘焙光照(就像实时灯光一样)。
在我们的情况下,我们的方向光只会影响地形,而且由于我们没有阴影,在 URP 中应用光照相对便宜,因此我们可以将方向光保持在实时模式,这样就不会占用任何光照贴图纹理区域。
我们聚光灯正在影响基础,但实际上,它们只是在应用光照到它们上——我们没有阴影,因为我们的基础是空的。在这种情况下,最好根本不计算光照贴图,但为了学习目的,我将在基础周围添加一些障碍物以投射一些阴影,从而证明使用光照贴图的合理性,如下面的图片所示:

图 12.45:添加对象以投射光照
在这里,你可以看到我们关卡的原设计在游戏开发过程中不断变化,这是你无法避免的——游戏的大部分内容会随着时间的推移而改变。现在,我们准备设置光照模式并执行烘焙过程,如下所示:
-
在层次中选择方向光。
-
将检查器窗口中通用部分的模式属性设置为实时(如果它还没有在这个模式下)。
-
选择两个聚光灯。
-
将它们的渲染模式设置为混合:

图 12.46:聚光灯的混合光照设置,方向光将处于实时模式
-
打开光照设置窗口(窗口 | 渲染 | 光照)。
-
我们想更改烘焙过程的某些设置。为了启用这些控制,点击新建光照设置按钮。这将创建一个具有光照贴图设置的资产,可以在我们想要多次使用相同设置的情况下应用于多个场景:

图 12.47:创建光照设置
- 降低光照贴图的质量,只是为了使过程更快。只需迭代,就可以通过使用如光照贴图分辨率、直接采样、间接采样和环境采样等设置轻松降低光照,所有这些设置都位于光照贴图设置类别下。在我的情况下,我已经应用了这些设置,如下面的图片所示。请注意,即使降低这些设置也需要时间;由于模块化关卡设计,场景中物体太多:

图 12.48:场景光照设置
-
点击生成光照,这是我们之前用来生成环境光照的同一个按钮。
-
等待过程完成。你可以通过检查 Unity 编辑器右下角的进度条来完成此操作。请注意,这个过程在大场景中可能需要数小时,所以请耐心等待:

图 12.49:烘焙进度条
- 在过程完成后,你可以检查光照设置窗口的底部部分,在那里你可以看到需要生成多少光照贴图。我们有最大光照贴图分辨率,所以我们可能需要几个来覆盖整个场景。此外,它还告诉我们它们的大小,以便我们可以考虑它们对内存的影响。最后,你可以查看烘焙光照贴图部分来查看它们:

图 12.50:生成的光照贴图
- 现在,基于结果,你可以移动对象,修改光照强度,或者进行任何必要的校正,以使场景看起来符合你的要求,并在每次需要时重新计算光照。在我的情况下,这些设置给了我足够好的结果,如图所示:

图 12.51:光照贴图结果
我们还有很多小的设置可以探讨,但我将留给你通过试错或阅读 Unity 关于光照贴图的文档来发现这些设置:docs.unity3d.com/Manual/Lightmappers.html。阅读 Unity 手册是获取知识的好来源,我建议你开始使用它——任何优秀的开发者,无论经验如何丰富,都应该阅读手册。
将静态光照应用于静态对象
当你在场景中将对象标记为静态时,你可能已经想到场景中的所有对象都不会移动,所以你可能为每个人检查了静态复选框。这是可以的,但你应该始终在场景中放入一个动态对象,以确保一切正常工作——没有任何游戏有完全静态的场景。尝试添加一个胶囊并将其移动,如图所示,以模拟我们的玩家。如果你注意观察,你会注意到一些奇怪的地方——由光照贴图过程生成的阴影没有被应用到我们的动态对象上:

图 12.52:光照贴图预计算阴影下的动态对象
你可能认为混合光照模式应该影响动态和静态对象,这正是它所做的事情。问题在于与静态对象相关的一切,包括它们投射的阴影,都被预先计算到那些光照贴图纹理中,而我们的胶囊是动态的,在预计算过程中它并不在那里。所以,在这种情况下,因为投射阴影的对象是静态的,它的阴影不会影响任何动态对象。
这里,我们有几种解决方案。第一种是将静态和实时混合算法更改为使靠近摄像机的所有内容使用实时照明并防止这个问题(至少在玩家的注意力焦点附近),这将大大影响性能。另一种选择是使用 光探针。当我们烘焙信息时,我们只在光照贴图上这样做,这意味着我们只在表面上有关光照的信息,而不是在空旷的空间中。因为我们的玩家正在穿越这些表面之间的空旷空间,我们不知道这些空间中的光照会是什么样子,比如走廊的中间。光探针是一组位于这些空旷空间中的点,Unity 也预先计算了这些信息,所以当一些动态对象通过光探针时,它将从它们那里采样信息。在下面的图像中,你可以看到一些已经应用于我们场景的光探针。你会注意到,那些处于阴影中的将会变暗,而那些暴露在光线下将会具有更大的强度。
此效果将应用于我们的动态对象:

图 12.53:代表光探针的球体
如果你现在将你的对象通过场景移动,它将对阴影做出反应,如下两个图像所示,你可以看到动态对象在烘焙阴影外被照亮,而在阴影内则是暗的:

图 12.54:从光探针接收烘焙照明的动态对象
为了创建光探针,请执行以下操作:
-
通过访问 GameObject | 光 | 光探针组 创建一个 光探针 组。
-
幸运的是,我们有一些关于如何定位它们的指导方针。建议将它们放置在光照变化的地方,比如在阴影内外。然而,这很复杂。最简单且推荐的方法是将光探针网格整个覆盖在你的可玩区域。为此,你可以简单地多次复制粘贴光网格组来覆盖整个基础:

图 12.55:光探针网格
- 另一种方法就是选择一个组,点击 编辑光探针 按钮进入光探针编辑模式:

图 12.56:光探针组编辑按钮
-
点击 全选 按钮然后 复制所选 以复制所有之前存在的探针。
-
使用翻译工具,将它们移动到前一个旁边,在这个过程中扩展网格。考虑一下,探针越靠近,你需要覆盖的地形就越多,这将生成更多的数据。然而,从性能角度来看,Light Probes 数据相对便宜,所以你可以有很多,如图 图 12.55 所示。
-
重复 步骤 4 和 步骤 5,直到你覆盖了整个区域。
-
使用 光照设置 中的 生成光照 按钮重新生成光照。
这样,你就已经预先计算了光照探针对动态物体的影响,将两个世界结合起来以获得一致的光照。
摘要
在本章中,我们讨论了几个光照主题,例如 Unity 如何计算光照和阴影,如何处理不同的光源,如直射光和间接光,如何配置阴影,如何烘焙光照以优化性能,以及如何结合动态光照和静态光照,以确保光照不会与它们影响的世界脱节。这是一个内容丰富的章节,但光照确实值得这样的关注。它是一个复杂的主题,可以极大地改善场景的外观和感觉,同时也能显著降低性能。这需要大量的实践,在这里,我们尝试总结所有你需要开始实验的重要知识。对这个主题要有耐心;很容易得到错误的结果,但你可能只需勾选一个复选框就能解决问题。
现在我们已经尽可能优化了场景设置,在下一章中,我们将使用 Unity 后处理堆栈应用最终的一层图形效果,这将应用全屏图像效果——那些能给我们带来如今所有游戏都有的电影般外观和感觉的效果。
加入我们的 Discord 社群!
与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。
扫描二维码或访问链接加入社群。

packt.link/handsonunity22
第十三章:带后处理的全屏效果
到目前为止,我们已经创建了不同的对象来改变场景的视觉效果,例如网格、粒子或灯光。我们可以调整这些对象的设置来提高场景质量,但当你将其与现代游戏场景进行比较时,你总会觉得缺少了什么,那就是后处理效果。在本章中,你将学习如何将效果应用于最终渲染帧,这将改变整个场景的外观。
在本章中,我们将检查以下图像效果概念:
-
使用后处理
-
使用高级效果
让我们先看看我们如何将后处理应用于我们的场景。
使用后处理
后处理 是一个 Unity 功能,允许我们将一系列效果(多个效果)一层层地叠加,这将改变图像的最终外观。每一个都会影响最终帧,根据不同的标准改变其中的颜色。在 图 13.1 中,你可以看到应用图像效果前后的场景。你会注意到一个巨大的差异,但那个场景中的对象没有任何变化,包括灯光、粒子或网格。
应用的是基于像素级别的效果。在这里看看这两个场景:

图 13.1:没有图像效果的场景(左)和添加了效果的相同场景(右)
需要注意的是,之前的后处理解决方案,后处理堆栈版本 2(PPv2)在 通用渲染管线(URP)上不会工作;它有自己的后处理实现,所以我们将在本章中看到这一点。它们非常相似,所以即使你正在使用 PPv2,你仍然可以从本章中学到一些东西。
在本节中,我们将讨论以下 URP 后处理概念:
-
设置配置文件
-
使用基本效果
让我们开始准备场景以应用效果。
设置配置文件
要开始应用效果,我们需要创建一个 配置文件,这是一个包含我们想要应用的所有效果和设置的资产。这是因为与材质相同的原因:因为我们可以在不同的场景和场景的部分之间共享相同的后处理配置文件。当我们提到场景的部分时,我们指的是应用了某些效果的游戏中的区域或体积。我们可以定义一个全局区域,无论玩家的位置如何都应用效果,或者我们可以应用不同的效果——例如,当我们处于户外或室内时。
在这个情况下,我们将使用一个全局体积,我们将用它来应用我们的第一个效果,方法如下:
-
创建一个新的空 GameObject(GameObject | 创建空对象)命名为
PP Volume(后处理体积)。 -
向其中添加 体积 组件,并确保 模式 设置为 全局。
-
点击配置文件设置右侧的新建按钮,这将生成一个与在点击按钮时选择的 GameObject 同名的新的配置文件资产(PPVolume 配置文件)。将该资产移动到其自己的文件夹中,这有助于资产组织。过程如图所示:

图 13.2:体积组件
-
为了测试体积是否工作,让我们添加一个效果。点击添加覆盖按钮,并选择后期处理 | 色差选项。
-
在色差效果的强度选项卡中勾选强度复选框,并将强度设置为
0.25,如图所示:

图 13.3:色差效果
- 现在,你将看到图像的角落应用了畸变效果。请记住在场景面板中查看这一点;我们将在下一步中将效果应用于游戏视图。这如图所示:

图 13.4:场景中应用了色差
- 现在,如果你按下播放并从主相机的视角观看游戏,你会发现效果没有被应用,这是因为我们需要检查主相机渲染部分的后期处理复选框,如图所示:

图 13.5:启用后期处理
因此,我们创建了一个全局体积,无论玩家的位置如何,都会将指定的覆盖效果应用于整个场景。
现在我们已经为使用后期处理准备好了场景,我们可以开始尝试不同的效果。让我们从下一节中最简单的效果开始。
使用基本效果
现在我们已经在场景中应用了后期处理,唯一需要做的就是开始添加效果并设置它们,直到我们得到期望的外观和感觉。为了做到这一点,让我们探索系统包含的几个简单效果。
让我们从色差开始,这是我们刚刚使用的,就像大多数图像效果一样,它试图复制特定的现实生活效果。所有游戏引擎渲染系统都使用一个简单的数学近似来描述眼睛视觉的实际工作方式,正因为如此,我们无法看到在人类眼睛或相机镜头中发生的一些效果。真实的相机镜头通过弯曲光线使其指向相机传感器,但这种弯曲在某些镜头中并不完美(有时是故意的),因此你可以看到如以下截图所示的扭曲:

图 13.6:无色差图像(左侧)和带有色差的同图像(右侧)
这个效果将是我们将添加的几个效果之一,以在我们的游戏中营造出电影感,模拟现实生活中的相机使用。当然,这种效果并不适合所有类型的游戏;也许简单的卡通风格不会从这种效果中受益,但你永远不知道:艺术是主观的,所以这是一个试错的过程。
此外,我们在之前的例子中稍微夸张了强度,以便使效果更明显,但我建议在这个场景中使用0.25的强度。通常建议对效果的强度要温和;虽然强烈的视觉效果很有吸引力,但因为你将添加很多效果,所以过一段时间后,图像会显得膨胀,扭曲过多。所以,尝试添加几个微妙的效果,而不是几个强烈的效果。但,再次强调,这取决于你寻找的目标风格;这里没有绝对真理(但常识仍然适用)。
最后,在讨论其他效果之前,如果你习惯于使用其他类型的后处理效果框架,你会注意到这个版本的色差设置较少,这是因为 URP 版本追求性能,所以它会尽可能简单。
我们接下来要讨论的效果是晕影。这是另一种相机镜头的缺陷,其中图像强度在镜头边缘丢失。这不仅可以用来自定义旧相机,还可以将用户的注意力引向相机的中心——例如,在电影场景中。
此外,如果你正在开发虚拟现实(VR)应用程序,这可以用来通过减少玩家的周边视野来减少运动病。在以下截图中,你可以看到一个旧相机上的晕影示例:

图 13.7:使用旧相机拍摄的照片,边缘有晕影
只是为了尝试一下,让我们通过以下步骤对我们的场景应用一些晕影效果:
-
选择
PP Volume游戏对象。 -
通过点击添加覆盖按钮添加后处理 | 晕影效果。
-
打开强度复选框,并将其设置为
0.3,以增强效果。 -
打开平滑度复选框,并将其设置为
0.5;这将增加效果的扩散。你可以在以下图中看到结果:

图 13.8:晕影效果
如果你想,可以通过勾选颜色复选框并设置另一个值来更改颜色;在我们的例子中,黑色可以很好地强化雨天环境。在这里,我邀请你检查其他属性,例如中心和圆角。你只需调整数值就能创造出很好的效果。
我们接下来要看到的效果是运动模糊,同样地,它模拟了相机的工作方式。真实的相机有一个曝光时间:它需要的时间来捕捉光子到图像中。当一个物体移动得足够快时,在短暂的曝光时间内,同一个物体会放置在不同的位置,因此它看起来会模糊。在下面的屏幕截图中,您可以看到应用在我们场景中的效果。在这种情况下,我们正在快速上下旋转相机,以下结果是:

图 13.9:运动模糊应用到我们的场景
有一个需要考虑的事情是,这种模糊只会应用于相机移动,而不是物体的移动(静止相机,移动物体),因为目前这个 URP 还不支持运动矢量。
为了使用这个效果,请按照以下步骤操作:
-
使用添加覆盖按钮添加后处理 | 运动模糊覆盖。
-
检查强度复选框并将其设置为
0.25。 -
在查看游戏视图(而不是场景视图)时旋转相机。您可以通过点击并拖动相机的变换的X属性(不是值——X标签),如图下所示:

图 13.10:改变旋转
如您所见,这种效果在场景视图中是不可见的,以及其他一些效果,所以在得出效果不起作用之前请考虑这一点。Unity 这样做是因为在场景中工作时有这种效果会非常烦人。
最后,我们将简要讨论两个最终简单的效果,胶片颗粒和白平衡。第一个很简单:添加它,将强度设置为1,您将得到老电影中著名的颗粒效果。您可以通过不同的数字设置类型来使其更微妙或更粗糙。白平衡允许您更改颜色温度,根据您的配置使颜色更暖或更冷。在我们的案例中,我们正在一个寒冷、昏暗的场景中工作,因此您可以添加它并将温度设置为-20来稍微调整外观并改善这种场景的外观和感觉。
现在我们已经看到了一些简单效果,让我们来看看受一些高级渲染功能影响的剩余效果。
使用高级效果
本节中我们将看到的效果与之前的效果没有太大区别;它们只是稍微复杂一些,需要一些背景知识才能正确使用。所以,让我们深入探讨它们吧!
在本节中,我们将看到以下高级效果概念:
-
高动态范围(HDR)和深度图
-
应用高级效果
让我们先讨论一些效果正常工作的要求。
高动态范围(HDR)和深度图
一些效果不仅与渲染图像一起工作,还需要额外的数据。我们首先讨论深度图,这是一个我们在上一章讨论过的概念。
总结一下,深度图是从摄像机的视角渲染的图像,但它不是生成场景的最终图像,而是渲染场景对象的深度,以灰度渲染对象。颜色越深,像素离摄像机越远,反之亦然。在以下屏幕截图中,您可以看到一个深度图的示例:

图 13.11:几个原始形状的深度图
我们将看到一些效果,例如景深,它将根据摄像机的距离模糊图像的某些部分,但可以在自定义效果中用于多种目的(不在基础 URP 包中)。
在这里讨论的另一个概念是高动态范围 (HDR),它将改变颜色的处理方式,从而影响某些效果的工作方式。在旧硬件中,颜色通道(红色、绿色和蓝色)被编码在 0 到 1 的范围内,0 表示没有强度,1 表示全强度(每个通道),因此所有光照和颜色计算都在这个范围内进行。这看起来似乎没问题,但并不反映光的实际工作方式。您可以在一张被阳光照亮的纸张上看到全白(所有通道设置为 1),您可以直接看到灯泡的全白,但即使光和纸张颜色相同,后者在一段时间后首先会刺激眼睛,其次,由于过多的光线,会有一些溢光。这里的问题是最大值(1)不足以表示最强烈的颜色,所以如果您有一个高强度的光源和另一个强度更高的光源,两者都会生成相同的颜色(每个通道的 1),因为计算不能超过 1。这就是为什么创建了HDR 渲染。
HDR 是一种让颜色超出 0 到 1 范围的方法,因此基于颜色强度工作的光照和效果在此模式下具有更好的准确性。这与新型电视机的 HDR 功能类似,尽管在这种情况下,Unity 将在 HDR 下进行计算,但最终图像仍然使用之前颜色空间(0 到 1,或低动态范围 (LDR))进行工作,所以不要混淆 Unity 的HDR 渲染与显示器的 HDR。
要将 HDR 计算转换回 LDR,Unity(以及电视)使用了一个称为色调映射的概念。您可以在以下屏幕截图中看到 LDR 渲染场景和色调映射在 HDR 场景中的应用示例:

图 13.12:使用色调映射校正过亮度的 LDR 渲染场景(左)和 HDR 场景(右)
色调映射是一种将超出 0-1 范围的颜色带回到该范围的方法。它基本上使用曲线来确定每个颜色通道应该如何映射回去。
你可以在典型的暗到亮的场景转换中清楚地看到这一点,例如当你从一个没有窗户的建筑中出来进入明亮的一天时。一段时间内,你会看到一切变亮,直到一切恢复正常。这里的想法是,当你身处室内或室外时,计算并没有不同;建筑内的白色墙壁将具有接近 1 的强度,而同一白色墙壁在室外将具有更高的值(由于阳光)。区别在于,当你身处室外时,色调映射会将高于 1 的颜色调整回 1,如果整个场景较暗,它可能会根据你的设置增加墙壁的照明。这个功能被称为自动曝光。
即使 HDR 默认启用,让我们看看如何检查它,通过以下操作:
-
前往编辑 | 项目设置。
-
点击左侧面板中的图形部分。
-
点击可脚本渲染管线设置属性下引用的资产。
-
点击项目面板中突出显示的资产。确保在点击图形设置中的属性之前,此面板是可见的。或者,你可以在图形设置中双击资产引用来选择它。
-
在质量部分,确保已勾选HDR,如图下截图所示:

图 13.13:启用 HDR
- 确保主相机游戏对象中相机组件的HDR属性设置为使用渲染管线设置,以确保上一步骤中的更改得到尊重。
当然,HDR 可切换的事实意味着存在一些你不想使用 HDR 的场景。正如你所猜想的,并非所有硬件都支持 HDR,使用 HDR 会带来性能开销,因此请考虑这一点。幸运的是,大多数效果都支持 HDR 和 LDR 色彩范围,所以如果你启用了 HDR 但用户设备不支持,你不会收到任何错误,只是效果根据不同的效果会有不同的结果,例如图像更亮或更暗,或者效果被夸大,正如我们将在下一节应用高级效果中看到的。
现在我们确信我们已启用 HDR,让我们探索一些使用此功能和深度映射的高级效果。
应用高级效果
让我们看看使用之前描述的技术的一些特定效果,从常用的辉光开始。此效果模拟了相机镜头或甚至人眼周围被强烈光照的物体上的溢光。在图 13.14中,你可以看到我们场景的默认版本和夸张的辉光版本的差异。
你可以观察到效果仅应用于场景中最亮的部分。看看这里两种效果:

图 13.14:默认场景(左)和具有高亮度辉光的同一场景(右)
这个效果实际上非常常见且简单,但我认为它是高级的,因为结果会受到 HDR 的极大影响。这个效果依赖于计算每个像素颜色的强度来检测可以应用溢光的位置。在 LDR 中,我们可以有一个不是过亮的白色物体,但由于这个颜色范围的限制,模糊效果可能会在其上产生溢光。在 HDR 中,由于其增加的颜色范围,我们可以检测一个物体是否是白色,或者物体可能是浅蓝色但只是过亮,从而产生它是白色的错觉(例如靠近高亮度灯的物体)。在图 13.15的屏幕截图中,你可以看到带有 HDR 和无 HDR 的我们场景之间的差异。你会注意到 LDR 版本将在不一定过亮的区域产生溢光。差异可能非常微妙,但请注意细节以注意差异。并且记住,我在这里夸大了效果。请看看这两个场景:

图 13.15:LDR 场景中的模糊效果(左)和 HDR 场景中的模糊效果(右)。注意,模糊设置被更改以尽可能接近它们
现在,让我们继续使用场景的 HDR 版本。为了启用模糊效果,请按照以下步骤操作:
-
按照惯例,将模糊覆盖添加到配置文件中。
-
通过勾选强度复选框来启用它,并将值设置为
0.2。这控制了将应用多少溢光。 -
启用阈值并将其设置为
0.7。这个值表示颜色需要达到的最小强度,才能被认为是溢光。在我们的例子中,我们的场景有些昏暗,因此我们需要在模糊效果设置中降低这个值,以便包含更多的像素。像往常一样,这些值需要根据你的具体情况进行调整。 -
你会注意到差异非常微妙,但再次提醒,你将拥有几个效果,所以所有这些细微的差异都会累积起来。你可以在下面的屏幕截图中看到这两个效果:

图 13.16:模糊效果
按照惯例,建议你调整其他值。我推荐你测试的一些有趣的设置是污渍纹理和污渍强度值,这些值将模拟在溢光区域中的脏镜头。
现在,让我们转向另一个常见的效果,景深。这个效果依赖于我们之前讨论过的深度图。这对肉眼来说并不明显,但当你专注于视线内的一个物体时,周围的物体因为不在焦点上而变得模糊。我们可以使用这个效果来在游戏的关键时刻吸引玩家的注意力。这个效果将采样深度图以查看物体是否在焦点范围内;如果是,则不应用模糊,反之亦然。
为了使用它,请按照以下步骤操作:
- 这种效果取决于你游戏中摄像机的位置。为了测试它,在这种情况下,我们将摄像机放置在柱子附近,试图聚焦于该特定对象,如下面的截图所示:

图 13.17:摄像机位置
-
添加景深覆盖。
-
启用并设置模式设置为高斯:在性能方面最经济的选项。
-
在我的情况下,我将开始设置为
10,将结束设置为20,这将使效果在目标物体后方一定距离处开始。结束设置将控制模糊强度如何增加,在20米处达到最大。请记住根据你的情况调整这些值。 -
如果你想要稍微夸张一下效果,将最大半径设置为
1.5。结果如下面的截图所示:

图 13.18:夸张效果
这里需要考虑的是,我们的游戏将采用俯视视角,与可以看见远处物体的第一人称摄像机不同,在这里,我们将有足够近的物体,以至于不会注意到效果,因此我们可以限制在场景中仅用于过场动画的效果使用。
现在,大多数剩余的效果都是改变场景实际颜色的不同方式。想法是,真实的颜色有时并不能给你你寻求的确切的外观和感觉。也许你需要将暗区调得更暗,以增强恐怖氛围的感觉,或者也许你想要相反:增加暗区以表示一个开阔的场景。也许你想要给高光稍微着色以获得霓虹效果,如果你正在制作未来派游戏,或者也许你想要暂时使用棕褐色效果来进行闪回。我们有无数种方法来做这件事,在这种情况下,我将使用一个简单但强大的效果,称为阴影,中间调,高光。
此效果将对阴影、中间调和高光应用不同的颜色校正,这意味着我们可以分别修改较暗、较亮和中等区域。让我们通过以下步骤尝试一下:
-
添加阴影 中间调 高光覆盖。
-
让我们开始进行一些测试。检查三个阴影、中间调和高光复选框。
-
将阴影和中间调滑块全部移到最左边,将高光滑块移到最右边。这将减少阴影和中间调的强度,并增加高光的强度。我们这样做是为了让你可以看到高光将根据其强度改变的区域。你可以用其余的滑块以同样的方式检查其他两个区域。你可以在下面的截图中看到结果:

图 13.19:隔离高光
- 此外,你也可以测试移动彩色圆圈中心的白色圆圈,为这些区域应用一点色调。通过将滑块稍微向左移动来减少高光的强度,使色调更加明显。你可以在下面的屏幕截图中看到结果:

图 13.20:色调高光
- 通过这样做,你可以探索这些控制如何工作,但当然,这些极端值对于某些边缘情况是有用的。在我们的场景中,以下屏幕截图中你可以看到的设置对我来说效果最好。一如既往,使用更微妙的值来不过度扭曲原始结果会更好,正如这里所示:

图 13.21:细微的变化
- 你可以在以下屏幕截图中看到前后效果:
图 13.22:前后效果
你还有其他更简单的选项,例如分割色调,它做的是类似的事情,但只是针对阴影和高光,或者颜色曲线,它让你可以更高级地控制场景中每个颜色通道的映射方式,但理念是相同的——也就是说,改变最终场景的实际颜色,为你的场景应用特定的色彩氛围。如果你记得电影系列《黑客帝国》,当角色处于矩阵中时,一切都有细微的绿色色调,而在外面时,色调是蓝色。
记住,使用 HDR 和不使用 HDR 对这些效果的结果很重要,因此最好是尽早而不是事后决定是否使用 HDR,排除某些目标平台(这些可能对你的目标受众并不重要),或者不使用 HDR(使用 LDR)并减少对场景照明级别的控制。
此外,考虑到你可能需要调整一些对象的设置,例如光照强度和材质属性,因为有时我们使用后期处理来修复由设置错误的对象引起的图形错误,这是不合适的。例如,增加场景中的环境光照将极大地改变效果输出,我们可以利用这一点来提高整体亮度,而不是使用效果,如果我们发现场景太暗的话。
这已经涵盖了要使用的主要图像效果。记住,理念不是使用每一个,而是使用你认为有助于你场景的效果;它们在性能方面并不是免费的(尽管不是那么资源密集),所以要明智地使用。此外,你可以检查已经创建的配置文件,将它们应用到你的游戏中,看看微小的变化可以带来巨大的差异。
摘要
在本章中,我们讨论了在场景中应用的基本和高级全屏效果,使其在相机镜头效果方面看起来更加逼真,在色彩扭曲方面更加时尚。我们还讨论了 HDR 和深度图的内幕以及它们在使用这些效果时的重要性,这些效果可以以最小的努力立即提高你游戏的图形质量。
既然我们已经涵盖了 Unity 系统中大部分常见的图形,让我们开始探讨如何通过使用声音来增加场景的沉浸感。
第十四章:声音与音乐整合
我们现在已经达到了足够的图形质量,但我们缺少游戏美学的一个重要部分:声音。声音通常被置于游戏开发的最后一步,它是那些即使存在,你也不会注意到其存在,但如果没有它,你会觉得缺少了什么的东西之一。它将帮助你强化你在游戏中想要营造的氛围,并且必须与图形设置相匹配。
在本章中,我们将探讨以下声音概念:
-
导入音频
-
音频整合与混音
我们将在我们的游戏中应用这些概念,导入音频以在不同的场景中播放——例如当玩家射击时——以及音乐。在后面的编程章节中,我们将播放声音,但现在,让我们专注于如何将它们导入到我们的项目中。
导入音频
与图形资产一样,为你的音频资产正确设置导入设置非常重要,因为如果设置不当,导入可能会非常消耗资源。
在本节中,我们将探讨以下音频导入概念:
-
音频类型
-
配置导入设置
让我们从讨论我们可以使用的不同类型的音频开始。
音频类型
在视频游戏中存在不同类型的音频,以下是一些:
-
音乐:根据情况增强玩家体验所使用的音乐。
-
音效(SFX):作为玩家或 NPC 动作的反应而发生的声音,例如点击按钮、行走、开门、开枪等等。
-
环境声音:仅对事件做出反应的游戏会感觉空洞。如果你正在在城市中间重建一个公寓,即使玩家只是在房间中间无所事事,也应该听到很多声音,其中大多数声音的来源将位于房间外,例如一架飞越头顶的飞机、两个街区外的建筑工地、街道上的汽车等等。创建不会看到的对象是浪费资源。相反,我们可以在场景的各个地方放置单个声音来重现所需的环境,但这将是资源密集型的,需要大量的 CPU 和 RAM 来实现可信的结果。考虑到这些声音通常占据用户注意力的第二平面,我们可以将它们全部组合成一个循环轨道,只播放一个音频文件,这就是环境声音。如果你想创建一个咖啡馆场景,你只需简单地去一个真实的咖啡馆,录制几分钟的音频,并将其用作你的环境声音。
对于几乎所有的游戏,我们至少需要一条音乐轨道、一条环境轨道和几个 SFX 来开始音频制作。像往常一样,我们有不同的音频资产来源,但我们将使用资产商店。它有三个音频类别来搜索我们需要的资产:

图 14.1:资产商店中的音频类别
在我的情况下,我也使用了搜索栏来进一步筛选分类,搜索weather以找到雨声效果。有时,你无法单独找到确切的音频;在这种情况下,你需要深入包和库,所以在这里要有耐心。在我的情况下,我选择了图 14.2中可以看到的三个包,但只导入了其中一些声音,因为所有这些声音在项目中的大小都会很大。对于环境音效,我选择了这个包中的一个名为Ambience_Rain_Moderate_01_LOOP的雨声文件,但如果你下载了另一个包,我们寻找的雨声文件名称可能不同。然后,我选择了音乐 – 悲伤的希望作为音乐,对于音效,我选择了一个枪声音效包,用于我们未来玩家的英雄角色。当然,你可以选择其他包以更好地满足你的游戏需求:

图 14.2:我们游戏的包
现在我们有了必要的音频包,让我们讨论如何导入它们。
配置导入设置
我们有多个可以调整的导入设置,但问题是我们需要考虑音频的使用情况来正确设置它,所以让我们看看每种情况下的理想设置。为了查看导入设置,像往常一样,你可以选择资产并在检查器面板中查看,如下面的图所示:

图 14.3:音频导入设置
让我们讨论最重要的几个,从强制单声道开始。一些音频可能包含立体声通道,这意味着我们左耳有一个声音播放,右耳有另一个声音。这意味着一段音频实际上可以包含两个不同的音频轨道。立体声音在音乐中用于不同的效果和乐器空间化,所以我们希望在那些情况下使用它,但还有其他情况下立体声并不适用。考虑 3D 音效,如枪声或一些行走步伐。在这些情况下,我们需要声音在源的方向上被听到——如果枪声在我左边响起,我需要听到它从左边传来。在这些情况下,我们可以通过在音频导入设置中勾选强制单声道复选框将立体声音频转换为单声道音频。这将使 Unity 将两个通道合并为一个,通常将音频大小减少到几乎一半(有时更多,有时更少,取决于各种因素)。
你可以在音频资产检查器的底部验证该设置和其他设置的影响,在那里你可以看到导入的音频大小:

图 14.4:左:未使用“强制单声道”导入的音频。右:使用“强制单声道”的相同音频
接下来要讨论的设置,并且是一个重要的设置,是加载类型。为了播放一些音频,Unity 需要从磁盘读取音频,解压缩它,然后播放。加载类型改变了这三个处理过程的方式。这里有以下三个选项:
-
加载时解压缩:最占用内存的选项。此模式将在场景加载时使 Unity 加载未压缩的音频到内存中。这意味着音频将在 RAM 中占用大量空间,因为我们已经加载了未压缩的版本。使用此模式的优势在于播放音频更容易,因为我们有原始音频数据准备在 RAM 中播放。
-
流式传输:与加载时解压缩正好相反。此模式永远不会将音频加载到 RAM 中。相反,当音频播放时,Unity 会从磁盘读取音频资产的一部分,解压缩它,播放它,并重复此过程,为流式传输中播放的每一部分音频运行一次。这意味着此模式将非常占用 CPU 资源,但将消耗几乎零字节的 RAM。
-
压缩在内存中:中间方案。此模式在场景加载时将从磁盘加载音频,但将其保持在内存中压缩状态。当 Unity 需要播放音频时,它只需从 RAM 中取出一部分,解压缩,然后播放。记住,从 RAM 中读取音频资产片段的速度远快于从磁盘读取。
也许如果你是一个经验丰富的开发者,你可以轻松地确定哪种模式更适合哪种类型的音频,但如果你是第一次接触视频游戏,这可能会听起来很困惑。所以,让我们讨论不同情况下的最佳模式:
-
频繁的短音频:这可能包括枪声或脚步声,这些声音持续不到一秒,但可以同时发生多个实例。在这种情况下,我们可以使用加载时解压缩。未压缩的短音频与压缩版本的大小差异不大。此外,由于这是性能最好的 CPU 选项,有多个实例不会对性能产生巨大影响。
-
不频繁的大音频:这包括音乐、环境声音和对话。这类音频通常只有一个实例播放,而且通常很大。这些情况更适合流式传输模式,因为它们在 RAM 中压缩或解压缩可能会对低端设备(如移动设备)的 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:音乐和环绕设置
音乐应配置为立体声(不勾选Force To Mono),使用Streaming Load Type,因为它们很大,并且将只有一个实例播放,以及ADPCM****Compression Format,因为Vorbis没有导致巨大的尺寸差异。
第二个截图显示了我是如何设置 SFX 文件(小文件)的:

图 14.6:射击 SFX 设置
我们下载的声音将是 3D 的,所以应该勾选Force To Mono。它们也将很短,所以Load Type中的Decompress On Load效果更好。最后,选择Vorbis Compression Format将ADPCM大小减少了超过一半,这就是我们选择它的原因。
现在我们已经正确配置了音频片段,我们可以在场景中使用它们了。
集成和混音音频
我们可以直接将音频片段拖入场景中开始使用,但我们可以进一步挖掘,探索将它们配置到每个可能场景的最佳方式。
在本节中,我们将检查以下音频集成概念:
-
使用 2D 和 3DAudioSource
-
使用音频混频器
让我们开始探索AudioSource,这些对象负责音频播放。
使用 2D 和 3DAudioSource
AudioSource是可以附加到 GameObject 上的组件。它们负责根据AudioClips(我们之前下载的音频资产)在我们的游戏中发出声音。
区分AudioClip和AudioSource很重要;我们可以有一个单一的爆炸AudioClip,但可以有多个AudioSource播放它,模拟多个爆炸。可以将AudioSource视为 CD 播放器,它可以播放AudioClip(在这个类比中是我们的 CD),只是我们可以在同一时间有多个 CD 播放器或AudioSource播放同一张 CD(例如,两个爆炸声同时播放)。
创建AudioSource的最简单方法是从AudioClip(音频资产)中选择一个,并将其拖到Hierarchy窗口中。尽量避免将音频拖入现有对象中;相反,在对象之间拖动它,这样 Unity 将创建一个新的带有AudioSource的对象,而不是将其添加到现有对象中(有时,你可能希望现有对象具有AudioSource,但现在让我们保持简单):

图 14.7:将AudioClip拖到对象之间的Hierarchy窗口
以下截图显示了通过将音乐资产拖到场景中生成的AudioSource。您可以看到AudioClip字段有一个对拖动音频的引用:

图 14.8:配置为播放我们的音乐资产的AudioSource
如您所见,AudioSource有多个设置,所以让我们在以下列表中回顾一下常见的设置:
-
唤醒时播放:确定音频是否在游戏开始时自动播放。我们可以取消选中它,并通过脚本播放音频,例如当玩家射击或跳跃时(关于这一点,请参阅本书的第三部分)。
-
循环:当音频播放完毕后,将自动重复播放。记得始终检查音乐和氛围音频剪辑上的此设置。由于这些轨道很长,我们在测试中可能永远无法到达它们的结尾,所以很容易忘记这一点。
-
音量:控制音频强度。
-
音调:控制音频速度。这对于模拟慢动作或发动机转速增加等效果非常有用。
-
空间混合:控制我们的音频是 2D 还是 3D。在 2D 模式下,音频将在所有距离上以相同的音量被听到,而 3D 模式将使音频音量随着与相机距离的增加而减小。
在我们的音乐轨道的情况下,我已经按照以下截图所示进行了配置。你可以将氛围雨声拖动到场景中,并使用相同的设置,因为我们希望所有场景中都有相同的氛围效果。然而,在复杂场景中,你可以在场景的各个地方分散放置不同的 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:主相机中的音频监听器组件
现在我们已经可以配置单个音频片段了,让我们看看如何使用音频混音器应用音频实例组的效果。
使用音频混音器
我们将在整个游戏中播放多个音频实例:角色的脚步声、射击、篝火、爆炸、雨等等。根据上下文精确控制哪些声音应该更响或更轻,以及应用效果来强化某些情况,例如由于附近的爆炸而受到惊吓,这被称为音频混音——以统一和受控的方式将多个声音混合在一起的过程。
在 Unity 中,我们可以创建一个音频混音器,这是一个资产,我们可以用它来定义声音组。对任何组的所有更改都会通过提升或降低音量,或许,或者通过应用效果来影响组内的所有声音。你可以有音效和音乐组来分别控制声音——例如,你可以在暂停菜单中降低音效音量,但不要降低音乐音量。此外,组以层次结构组织,其中组也可以包含其他组,因此对组的更改也会应用到其子组。实际上,你创建的每个组都将始终是主组的子组,即控制游戏中每个声音(使用该混音器)的组。
让我们创建一个带有音效和音乐组的混音器:
-
在项目窗口中,使用+按钮,选择音频混音器选项。根据你的意愿命名资产;在我的情况下,我选择了
Main Mixer。 -
双击创建的资产以打开音频混音器窗口:

图 14.13:音频混音器窗口
- 点击+按钮,在组标签的右侧创建主节点的子组。命名为
SFX:

图 14.14:创建组
- 点击主分组,然后再次点击+按钮以创建另一个名为
Music的主节点子分组。记住在点击+按钮之前选择主分组,因为如果选择了另一个分组,新分组将成为该分组的子分组。无论如何,你可以通过在音频混音器窗口的分组面板中拖动分组来重新排列分组子父关系:

图 14.15:主、SFX 和音乐分组
-
在层次结构窗口中,选择我们场景中的音乐GameObject,并在检查器窗口中查找音频源组件。
-
点击输出属性右侧的圆圈以打开音频混音器分组选择窗口,并选择音乐分组。这将使受该混音器分组设置的音频源受到影响:


- 如果你现在玩游戏,你可以看到在音频混音器窗口中的音量表开始移动,这表明音乐正在通过音乐分组。你也会看到主分组音量表也在移动,这表明通过音乐分组的音也在通过主分组(音乐分组的父分组)前往你的电脑的声卡:

图 14.17:分组音量级别
- 重复步骤 5和步骤 6,将环境音和射击音设置为属于SFX分组。
现在我们已经将声音分成了组,我们可以开始调整分组的设置。但是,在这样做之前,我们需要考虑到我们不会一直想要相同的设置,就像之前提到的暂停菜单案例中,SFX 音量应该更低。为了处理这些场景,我们可以创建快照,这些是我们的混音器的预设,可以在游戏运行期间通过脚本激活。我们将在本书的第三部分中处理脚本步骤,但我们可以为游戏设置创建一个正常快照,为暂停菜单设置创建一个暂停快照。
如果你检查快照列表,你会看到已经创建了一个快照——这可以是我们的正常快照。所以,让我们创建一个暂停快照,方法如下:
-
点击快照标签右侧的+按钮,并将快照命名为“暂停”。记住在编辑混音器时停止游戏,或者点击在播放模式下编辑选项以允许 Unity 在播放时更改混音器。如果你这样做,记住当你停止游戏时,这些更改将保持不变,与 GameObject 的更改不同。实际上,如果你在播放模式下更改其他资产,这些更改也将保持不变——只有 GameObject 的更改会被撤销。
有一些其他情况,比如材料和动画,由于它们是资产,暂停后这些更改不会被撤销,但我们现在不会讨论它们:

图 14.18:快照创建
- 选择暂停快照并降低SFX组的音量滑块:

图 14.19:降低暂停快照的音量
-
播放游戏并听一下声音是否仍然处于正常音量。这是因为原始快照是默认的——你可以通过检查其右侧的星号来确认。你可以右键单击任何快照,并使用设置为起始快照选项将其设置为默认快照。
-
点击在****游戏模式下编辑以在运行时启用音频混音器的修改。
-
点击暂停快照以启用它并听一下射击和环境声音音量是如何降低的。
如你所见,混音器的主要用途之一是控制组音量,尤其是在你看到某个组的音量强度高于 0 标记时,这表明该组声音太大。无论如何,混音器还有其他用途,比如应用效果。如果你玩过任何战争游戏,你会注意到每当附近有炸弹爆炸时,你会在一段时间内听到不同的声音,就像声音位于另一个房间一样。这可以通过一个名为低通的效果来实现,它阻止高频声音,这正是我们在那些场景中耳朵所经历的情况:爆炸产生的高音量声音的压力会刺激我们的耳朵,使它们对高频的敏感性降低一段时间。
我们可以向任何通道添加效果,并根据当前快照进行配置,就像我们为音量所做的那样,方法如下:
- 在主组底部点击添加…按钮并选择低通简单:

图 14.20:通道的效果列表
-
选择正常快照(称为
快照)以进行修改。 -
选择主组并查看检查器面板,在那里你会看到组及其效果的设置。
-
将低通简单设置的截止频率属性设置为最高值(
22000),这将禁用该效果。 -
对于暂停快照,重复步骤 3和步骤 4;我们不想在那个快照中应用这个效果。
-
创建一个名为炸弹击晕的新快照并选择它进行编辑。
-
将截止频率设置为
1000:

图 14.21:设置低通简单效果的截止频率
- 播放游戏并在快照之间切换以检查差异。
除了低通滤波器之外,您还可以应用其他几个过滤器,例如Echo,以创建几乎梦幻般的效果,或者Send、Receive和Duck的组合,以根据另一个组的强度降低一个组的音量(例如,当发生对话时,您可能希望降低 SFX 音量)。我邀请您尝试这些和其他效果,并检查结果以通过阅读以下文档来识别潜在用途:docs.unity3d.com/Manual/class-AudioEffectMixer.html。
现在我们已经集成了音频,让我们看看我们如何可以编写我们的音频脚本。
脚本化音频反馈
就像 VFX 一样,音频也需要对游戏中的事件做出反应,以提供更好的沉浸感。让我们开始添加当敌人死亡时产生的爆炸效果的声音,这本身可能不需要脚本,但它是由于最初生成爆炸的脚本而产生的:
-
从互联网或资产商店下载爆炸声音效果。
-
选择当敌人死亡时我们生成的Explosion预制体,并为其添加Audio Source。
-
将下载的爆炸音频剪辑设置为音频源的AudioClip属性。
-
确保在Audio Source下Play On Awake被选中,Loop未被选中:

图 14.22:将声音添加到我们的爆炸效果中
如您所见,我们在这里没有使用任何脚本。当声音被添加到预制体中时,它将在预制体实例化的那一刻自动播放。现在,让我们通过以下步骤整合射击声音:
-
下载一个射击声音,并通过音频源将其添加到玩家的武器枪口效果(而不是武器)中,这次取消选中Play On Awake复选框。
-
在
PlayerShooting脚本中,创建一个名为shootSound的AudioSource类型的字段。 -
在Hierarchy中选择Player,并将武器枪口效果 GameObject 拖到Inspector中的Shoot Sound属性,以将脚本与武器枪口效果的
AudioSource变量连接起来。 -
在检查我们是否可以射击的
if语句中,添加shootSound.Play();行以在射击时执行声音:

图 14.23:射击时添加声音
视觉脚本附加节点将看起来像这样:

图 14.24:在视觉脚本中射击时添加声音
就像我们对枪口效果所做的那样,我们添加了一个名为shootSound的 GameObject 变量来引用包含AudioSource的武器 GameObject,然后我们调用了shootSound变量的Play方法。
我挑战你尝试在 C#和 Visual Scripting 版本的脚本中为敌方 AI 添加射击声音。以我们在第十一章中做的为例,使用粒子系统和视觉效果图进行视觉效果,对于枪口效果,无论如何,你都可以查看本书的 Git 仓库(可在前言中找到链接)以获取解决方案。
对于这一点,我们可以采取与我们在处理爆炸时相同的方法;只需将射击声音添加到子弹上,但如果子弹撞到墙壁,声音很快就会停止。或者,如果我们未来想要自动武器的声音,它需要实现为一个循环播放的声音,当我们按下相关键时开始,当我们释放它时停止。这样,我们就可以防止在射击过多子弹时出现太多声音实例重叠。在选择编写反馈脚本的方法时,考虑到这些类型的场景。
摘要
在本章中,我们讨论了如何导入和集成声音,考虑到它们对内存使用的影响,并考虑了如何应用效果来生成不同的场景。声音是实现期望游戏体验的重要组成部分,因此请花适当的时间来确保其正确性。
现在我们已经涵盖了游戏几乎所有重要的美学方面,让我们再创造一种视觉沟通形式,即用户界面或 UI。我们将在下一章创建必要的 UI 来显示玩家的当前得分、子弹、生命值以及更多信息。
第十五章:用户界面设计
屏幕上显示并通过计算机扬声器传输的每一件事,都是一种沟通方式。在之前的章节中,我们使用了 3D 模型来让用户知道他们身处山中的基地,并通过适当的声音和音乐强化了这个想法。但对我们游戏来说,我们需要传达其他信息,例如玩家剩余的生命值和当前得分,有时,使用游戏内的图形来表达这些事情是有困难的(有一些成功的案例能够做到这一点,例如死亡空间,但让我们保持简单)。
为了传输此类信息,我们需要在我们的场景之上添加另一层图形,这通常被称为用户界面(UI)。这将包含不同的视觉元素,例如文本字段、条形图和按钮,以便用户能够根据生命值低时逃往安全地点等情况做出明智的决定。
在本章中,我们将探讨以下主题:
-
理解 Canvas 和 RectTransform
-
Canvas 对象类型
-
创建响应式 UI
到本章结束时,你将能够使用 Unity UI 系统创建能够通知用户游戏状态并允许他们通过按按钮采取行动的界面。让我们首先讨论 Unity UI 系统的基本概念——Canvas 和 RectTransform。
理解 Canvas 和 RectTransform
我们将只关注游戏内的 UI,使用 Unity GUI 系统(或 uGUI)向玩家传达不同的信息。在撰写本书时,一个新的 GUI 系统名为 UI Toolkit 已经发布,但 uGUI 仍将存在一段时间,因为 UI Toolkit 将主要用于新项目,并且仍然能够处理所有类型的 UI。我们将在下一章探讨 UI Toolkit。
如果你打算使用 Unity UI,你首先需要理解其两个主要概念——Canvas和RectTransform。Canvas是包含并渲染我们的 UI 的主对象,而RectTransform是负责在屏幕上定位和调整每个 UI 元素的特性。
在本节中,我们将讨论以下内容:
-
使用 Canvas 创建 UI
-
使用 RectTransform 定位元素
让我们从使用 Canvas 组件来创建我们的 UI 开始。
使用 Canvas 创建 UI
在 Unity UI 中,UI 中看到的每个图像、文本和元素都是一个具有一组适当组件的 GameObject,但为了使它们能够工作,它们必须是一个具有 Canvas 组件的主 GameObject 的子对象。这个组件负责触发 UI 生成并在每个子对象上绘制迭代。我们可以配置这个组件来指定该过程的确切工作方式,并适应不同的可能需求。
首先,你可以简单地通过选择GameObject | UI | Canvas选项来创建一个画布。完成此操作后,你将在场景中看到一个矩形,它代表用户屏幕,因此你可以将其内部放入元素并预览它们相对于用户监视器的位置。
你可能在这里有两个疑问。首先,“为什么场景中间有一个矩形?我希望它始终显示在屏幕上!”不用担心,情况确实如此。当你编辑 UI 时,你会看到它作为关卡的一部分,作为其中的一个对象,但当你玩游戏时,它将始终投影在屏幕上,覆盖在所有对象之上。此外,你可能想知道为什么矩形这么大,这是因为在使用默认的画布****渲染模式,即称为屏幕空间 - 覆盖时,屏幕映射中的一个像素对应场景中的一米。还有其他模式,但讨论它们超出了本章的范围。
再次强调,不要担心这个问题;当你从游戏视图中查看游戏时,你将看到所有 UI 元素在用户屏幕上的正确大小和位置。考虑到场景视图将遵循游戏视图的尺寸,建议在场景视图中编辑之前先设置游戏视图的大小。你可以通过点击游戏面板顶部说自由纵横比的下拉菜单,并选择所需的分辨率或纵横比来完成此操作,16:9 纵横比是最常用的选项:

图 15.1:默认图像 UI 元素——一个白色框
在向我们的 UI 添加元素之前,值得注意的是,当你创建 UI 时,与 Canvas 一起创建了一个名为EventSystem的第二个对象。这个对象对于渲染 UI 不是必需的,但如果你想让 UI 可交互,即包括点击按钮、在字段中输入文本或使用摇杆导航 UI 等动作,则是必需的。EventSystem组件负责采样用户输入,如键盘、鼠标或摇杆,并将这些数据发送到 UI 以做出相应反应。我们可以更改与 UI 交互的确切按钮,但默认设置现在是可以接受的,所以只需知道,如果你想与 UI 交互,你需要这个对象。如果由于某种原因你删除了这个对象,你可以在GameObject | UI | Event System中重新创建它。
现在我们有了创建 UI 的基础对象,让我们向其中添加元素。
使用 RectTransform 定位元素
在 Unity UI 中,你看到的每个图像、文本和 UI 元素都是一个具有根据其使用情况设置的组件的 GameObject,但你将看到它们大多数都有一个共同的组件——RectTransform。UI 的每一部分本质上都是一个填充有文本或图像的矩形,并且具有不同的行为,因此理解RectTransform组件的工作原理以及如何编辑它非常重要。
为了实验这个组件,让我们创建并编辑 UI 的一个简单白色框元素的定位,如下所示:
- 进入GameObject | UI | Image。之后,你将看到在Canvas元素内创建了一个新的 GameObject。Unity 将负责将任何新的 UI 元素设置为 Canvas 的子元素;在其外部,该元素将不可见:

图 15.2:默认图像 UI 元素——一个白色框
- 点击Scene视图顶部的 2D 按钮。这将仅更改 Scene 视图的视角,使其更适合编辑 UI(以及 2D 游戏):

图 15.3:2D 按钮位置
-
在Hierarchy窗口中双击 Canvas,使 UI 完全适合 Scene 视图。这将允许我们清楚地编辑 UI。你还可以使用鼠标滚轮导航 UI 进行缩放,点击并拖动滚轮来平移相机。
-
启用RectTransform工具,这是 Unity 编辑器左上角第五个按钮(或按T键)。这将启用矩形辅助工具,允许你移动、旋转和缩放 2D 元素,而不会像常规 3D 变换辅助工具那样引起问题:

图 15.4:矩形辅助工具按钮
- 使用矩形辅助工具,拖动对象以移动它,使用蓝色点来更改其大小,或将鼠标定位在蓝色点附近,直到光标变成曲线箭头以旋转它。请注意,使用此辅助工具调整对象大小与缩放对象不同,稍后我们将详细介绍这一点:

图 15.5:编辑 2D 元素的矩形辅助工具
- 在Inspector窗口中,注意在更改 UI 元素的大小后,Rect Transform设置的Scale属性仍然为
1,1,1,但你可以看到Width和Height属性已发生变化。RectTransform本质上是一个经典的变换,但增加了Width和Height(以及其他稍后要探索的属性)。你可以在这里设置你想要的精确值,以像素为单位:

图 15.6:Rect Transform 属性
现在我们已经了解了如何定位任何 UI 对象的基础知识,让我们探索可以添加到 Canvas 的不同类型的元素。
Canvas 对象类型
到目前为止,我们使用的是最简单的 Canvas 对象类型——一个白色框,但我们可以使用许多其他对象类型,例如图像、按钮和文本。所有这些都使用RectTransform来定义它们的显示区域,但每个都有其自己的概念和配置需要理解。
在本节中,我们将探索以下 Canvas 对象概念:
-
集成 UI 资产
-
创建 UI 控件
让我们先探索如何将图像和字体集成到 Canvas 中,以便我们可以使用Images和TextUI 对象类型将它们集成到 UI 中。
集成 UI 资产
在我们的 UI 使用漂亮的图形资产之前,我们需要将它们正确集成到 Unity 中。在下面的屏幕截图中,您将找到我们为游戏提出的 UI 设计:

图 15.7:UI 设计
此外,我们还将添加一个暂停菜单,当用户按下Esc键时将被激活。它看起来如下截图所示:

图 15.8:暂停菜单设计
根据这些设计,我们可以确定我们需要以下资产:
-
英雄的头像图片
-
一个健康条图片
-
一个暂停菜单背景图片
-
一个暂停菜单按钮图片
-
文本字体
和往常一样,我们可以在互联网或 Asset Store 上找到所需的资产。在我的情况下,我将使用两者的混合。让我们从最简单的一个开始——头像。按照以下步骤操作:
-
从互联网上下载您想要的头像,比如一个角色的面部图像。
-
将其添加到您的项目中,可以通过将其拖动到项目窗口或使用资产 | 导入新资产选项来实现。将其添加到
精灵文件夹。 -
选择纹理,并在检查器窗口中,将纹理类型设置为精灵(2D 和 UI)。所有纹理默认都准备用于 3D。此选项将我们的纹理准备用于 2D 环境,如 UI 和 2D 游戏。
对于条形、按钮和窗口背景,我将使用 Asset Store 来寻找一个 UI 包。在我的情况下,我在下面的屏幕截图中找到了一个很好的包来开始我的 UI。像往常一样,请记住,这个确切的包可能现在不可用。在这种情况下,请记住寻找另一个类似的包,或者从 GitHub 仓库中选择精灵:

图 15.9:选定的 UI 包
首先,这个包包含许多以相同方式配置的图像,作为精灵,但我们可以进一步修改导入设置以实现高级行为,这对于按钮是必需的。按钮资产有一个固定的大小,但如果你需要一个更大的按钮怎么办?一个选项是使用不同大小的其他按钮资产,但这会导致按钮和其他资产(如不同大小的窗口背景)的大量重复,这会不必要地消耗 RAM。
另一个选项是使用九宫格方法,它包括将图像分割成四个角落与其他部分分离。这允许 Unity 拉伸图像的中间部分以适应不同的大小,同时保持角落的原有大小。当与为九宫格技术准备的图像结合使用时,可以用来创建几乎任何你需要的尺寸。
在图 15.10中,您可以在左下角看到一个有九个切片的形状,在相同图表的右下角,您可以看到形状被拉伸但保持了原始大小的角落。右上角显示了没有切片的形状拉伸。您可以看到非切片版本是如何变形的:

图 15.10:切片与非切片图像拉伸对比
在这种情况下,我们可以将九个切片应用于按钮和面板背景图像,以便在游戏的各个部分使用它们。为了做到这一点,请按照以下步骤操作:
-
使用窗口 | 包管理器选项打开包管理器。
-
通过将窗口左上角+按钮右侧的下拉菜单设置为Unity Registry来验证包管理器是否显示所有包。
-
安装2D Sprite包以启用精灵编辑工具(如果尚未安装)。
-
在项目窗口中选择按钮精灵,然后在检查器窗口中点击精灵编辑器按钮:

图 15.11:检查器窗口中的精灵编辑器按钮
-
在精灵编辑器窗口中,找到并拖动图像边缘的绿色点以移动切片标尺。尽量确保切片不在按钮边缘的中间。要注意的一件事是,在我们的情况下,我们将使用三个切片而不是九个,因为我们的按钮不会垂直拉伸。如果您看不到点,请尝试点击图像使它们出现。
-
注意,在拖动绿色点之后,窗口右下角的边框属性(L、T、R和B,分别代表左、上、右和下)发生了变化。这些就是您通过移动绿色点设置的精确值。您可以随意将它们更改为更圆的数字,以便 9 个切片均匀工作。在我们的例子中,左右变成了 60 的整数,上下变成了 50。
-
点击窗口右上角的应用按钮并关闭它:

图 15.12:精灵编辑器窗口中的九个切片
- 重复步骤 4到6以处理背景面板图像。在我的情况下,您可以在图 15.13中看到,这个背景并不是完全按照九个切片来准备的,因为图像的所有中间区域都可以缩小以节省内存。
当以较小的宽度显示此图像时,9 切片方法会拉伸中间部分,看起来相同,所以本质上浪费了内存:

图 15.13:精灵编辑器窗口中的九个切片
现在我们已经准备好了我们的精灵,我们可以找到一个字体来自定义 UI 的文本。在讨论如何导入字体之前,值得提一下,我们将使用TextMesh Pro,这是一个 Unity 包(已包含在项目中),它提供了一个比旧文本组件更好的文本渲染解决方案。如果您之前从未使用过该组件,您不必担心这个细节。
你必须获取.ttf或.otf格式的字体并将它们导入到 Unity 中。互联网上有许多优秀的免费字体网站。我习惯于使用经典的DaFont.com网站,但还有很多其他你可以使用的网站。在我的情况下,我将使用Militech字体:

图 15.14:我在 DaFont.com 上选择的用于项目的字体
如果字体下载包含多个文件,你只需将它们全部拖入 Unity,然后使用你最喜欢的一个。同样,通常情况下,尝试将字体放在名为Fonts的文件夹中。现在,这些文件的格式与我们的文本渲染解决方案 TextMesh Pro 不兼容,因此我们必须使用字体资产创建器窗口进行转换,如下面的步骤所示:
-
前往窗口 | Text Mesh Pro | 字体资产创建器。
-
如果这是你第一次在项目中使用 Text Mesh Pro,会出现一个窗口。你必须点击导入 TMP 基础组件选项并等待导入过程完成:

图 15.15:TextMesh Pro 首次运行初始化
-
关闭TMP 导入器窗口。
-
在字体资产创建器中,将你的字体从项目视图拖动到源字体文件,或者通过点击右侧的目标按钮(中心带点的圆形)来选择它。
-
点击生成字体图集按钮并稍等片刻:

图 15.16:将字体资产转换为 TextMesh Pro
- 点击保存按钮,并将转换后的字体保存到TextMesh Pro | 资源 | 字体与材质文件夹。在这里保存很重要,所以不要忘记选择正确的文件夹:

图 15.17:在正确的文件夹中保存转换后的字体(Mac)
现在我们已经拥有了创建 UI 所需的所有资产,让我们探索不同类型的组件以创建所有必要的 UI 元素。
创建 UI 控件
几乎 UI 的每一个部分都将是由图像和文本巧妙配置的组合。在本节中,我们将探索如何创建图像、文本和按钮,从图像开始。我们 UI 中已经有一个图像——我们之前创建的白色矩形。如果你选择它并查看检查器窗口,你会注意到它有一个Image组件,如下面的截图所示:

图 15.18:Image 组件的检查器窗口
让我们从探索这个组件的设置开始,首先是我们的英雄头像:
- 使用矩形工具,将白色矩形移动到 UI 的左上角:

图 15.19:位于 UI 左上角的白色矩形
- 在检查器窗口中,点击源图像属性右侧的圆形,并选择下载的英雄头像精灵:

图 15.20:设置我们的 Image 组件的精灵
- 我们需要纠正图像的宽高比以防止扭曲。一种方法是在图像组件底部的设置原生尺寸按钮上单击,使图像使用与原始精灵相同的大小。然而,这样做,图像可能会变得太大,因此您可以按Shift键修改宽度和高度值以减小图像大小。另一种选项是勾选保留宽高比复选框以确保图像适合矩形而不会拉伸。在我的情况下,我将使用两者:

图 15.21:保留宽高比和设置原生尺寸的图像选项
现在,让我们通过以下步骤创建生命条:
-
使用GameObject | UI | Image选项创建另一个图像组件。
-
将源图像属性设置为下载的生命条图像:

图 15.22:头像和生命条
-
将图像类型属性设置为填充。
-
将填充方法属性设置为水平。
-
拖动填充量滑块以查看根据滑块值如何切割条形。我们将在第十八章使用 Profiler、帧调试器和内存 Profiler 进行优化中通过脚本更改该值:
![图片]()
图 15.23:填充量滑块,切割图像宽度为原始大小的 73%
-
在我的情况下,条形图像还附带了一个条形框架,创建另一个图像,设置精灵,并将其定位在生命条上方以形成框架。请注意,层次结构窗口中对象的显示顺序决定了它们将被绘制的顺序。因此,在我的情况下,我需要确保框架 GameObject 位于健康条图像下方。此外,考虑到条形框架图像未切片,因此在这种情况下不需要使用切片 图像 类型。您可以自由尝试切片并查看结果:

图 15.24:将一个图像放在另一个图像上方以创建框架效果
- 重复步骤 1到6以创建底部的基线条,或者只需复制并粘贴条和框架,并将其定位在屏幕底部:

图 15.25:玩家和玩家基地的健康条
-
在项目窗口中点击+按钮,并选择精灵 | 正方形选项。这将创建一个具有 4x4 分辨率的简单方形精灵。
-
将精灵设置为玩家基地生命条的基线条,而不是下载的条形精灵。这次,我们将使用纯白色图像作为条形,因为在我的情况下,原始的是红色,将红色图像的颜色调整为绿色是不可能的。然而,白色图像可以很容易地着色。考虑到原始条形的细节——例如,我原始条形中的小阴影在这里将不会出现。
-
选择基础生命条,并将颜色属性设置为绿色:

图 15.26:具有方形精灵和绿色着色的条
- 一个可选的步骤是将条形框架图像转换为九分割图像,以便我们可以调整原始宽度以适应屏幕。
现在,让我们通过以下方式添加得分、子弹、剩余波浪和剩余敌人标签的文本字段:
-
使用GameObject | UI | Text - Text Mesh Pro选项(避免只说Text的那个选项)创建一个文本标签。这将作为得分标签。
-
将标签放置在屏幕的右上角。
-
在检查器窗口中,将文本输入属性设置为
得分: 0。 -
将字体大小属性设置为
20。 -
通过单击字体资产属性右侧的圆圈并选择所需的字体来应用转换后的字体。
-
在对齐属性中,选择第一行的第三个按钮水平右对齐图标和第二行的第二个按钮垂直居中对齐图标:

图 15.27:文本标签的设置
- 重复步骤 1到6以创建其他三个标签(或者只需复制粘贴得分三次)。对于剩余波浪标签,您可以使用左对齐选项以更好地匹配原始设计:

图 15.28:我们 UI 的所有标签
- 将所有标签的颜色设置为白色,因为我们的场景将主要是暗色调。
现在我们已经完成了原始 UI 设计,我们可以创建暂停菜单:
-
为菜单的背景创建一个图像组件(GameObject | UI | Image)。
-
使用我们之前制作的九分割精灵设置背景面板。
-
如果尚未设置,将图像类型属性设置为分割。此模式将应用 9 分割缩放方法,以防止角落拉伸。
-
有可能图像仍然会拉伸角落,这是因为有时角落相对于您使用的RectTransform设置的大小属性相当大,所以 Unity 没有其他选择,只能这样做。在这种情况下,正确的解决方案是让一位艺术家创建适合您游戏的资产,但有时我们没有这个选择。这次,我们只需增加精灵文件的每单位像素值,这将减小原始图像的缩放,同时保留其分辨率。在下面的两个屏幕截图中,您可以看到具有每单位像素值为
100的背景图像,以及再次设置为700的图像。请记住,只为九分割或平铺图像类型这样做,或者如果您没有艺术家来调整它:

图 15.29:顶部是一个大九分割图像,位于一个足够小的 RectTransform 组件上,足以缩小角落,底部是设置每单位像素为 700 的相同图像
-
创建一个TextMesh Pro文本字段,将其放置在你想要在图中显示暂停标签的位置,设置为显示暂停文本,并设置字体。记住,你可以使用颜色属性更改文本颜色。
-
将文本字段拖动到背景图像上。画布中的父子系统工作方式相同——如果你移动父对象,子对象也会随之移动。我们的想法是,如果我们禁用面板,它也会禁用按钮及其所有内容:

图 15.30:暂停标签
-
通过访问游戏对象 | UI | 按钮 - 文本网格 Pro(避免使用只说按钮的那个)创建两个按钮。将它们放置在背景图像上的你想要的位置。
-
通过在层次结构窗口中拖动它们,将它们设置为暂停背景图像的子对象。
-
选择按钮,并将它们图像组件的源图像属性设置为使用我们之前下载的按钮精灵。如果你遇到与之前相同的问题,请记住我们的每单位像素修复,即本列表中的步骤 4。
-
你会注意到按钮本质上是一个带有子TextMesh Pro文本对象的图像。更改每个按钮的字体以及每个按钮中的文本为
Resume和Quit:

图 15.31:暂停菜单实现
- 记住,你可以通过取消勾选检查器窗口顶部对象名称右侧的复选框来隐藏面板:

图 15.32:禁用 GameObject
在本节中,我们讨论了如何通过图像、文本和按钮组件导入图像和字体,以创建丰富且信息丰富的 UI。完成这些后,让我们讨论如何使它们适应不同的设备。
创建响应式 UI
现在,几乎不可能在单个分辨率下设计 UI,我们的目标受众显示设备可能差异很大。PC 有多种不同类型的显示器,具有不同的分辨率(如 1080p 和 4k)和纵横比(如 16:9、16:10 和超宽),移动设备也是如此。我们需要准备我们的 UI 以适应最常见的显示,而 Unity UI 拥有完成这一任务的工具。
在本节中,我们将探讨以下 UI 响应性概念:
-
适应对象位置
-
适应对象大小
我们将探讨如何使用画布和RectTransform组件的高级功能,如锚点和缩放器,使 UI 元素能够适应不同的屏幕尺寸。
适应对象位置
目前,如果我们运行我们的游戏,我们将看到 UI 如何很好地适应我们的屏幕。但如果出于某种原因我们更改游戏视图大小,我们将看到对象如何开始从屏幕上消失。在以下屏幕截图中,你可以看到不同大小的游戏窗口以及 UI 在一个中看起来很好,而在其他中则不好:

图 15.33:相同的 UI 但在不同屏幕尺寸下
问题在于我们使用编辑器中拥有的任何分辨率创建了 UI,但一旦我们稍微改变它,UI 就会保留之前分辨率的布局。此外,如果你仔细观察,你会注意到 UI 总是居中的,例如在第二张图片中,UI 在其边缘被裁剪,或在第三张图片中,屏幕边缘可以看到额外的空间。这是因为 UI 中的每个元素都有自己的 Anchor,当你选择一个对象时可以看到一个小十字形,如下面的截图所示:

图 15.34:屏幕右下角属于屏幕左上角英雄头像的锚点十字形
物体的 x 和 y 位置是以到该锚点的距离来测量的,而锚点相对于屏幕的位置是已知的,其默认位置位于屏幕中心。这意味着在一个 800 x 600 的屏幕上,锚点将被放置在 400 x 300 的位置,而在一个 1920 x 1080 的屏幕上,锚点将位于 960 x 540 的位置。如果元素的 x 和 y 位置(在 RectTransform 中)为 0,则对象将始终与中心保持 0 距离。在前三个示例的中间截图,英雄头像落在了屏幕外,因为其与中心的距离大于屏幕的一半,而当前距离是根据之前更大的屏幕尺寸计算的。那么我们能做什么呢?移动锚点!
通过设置相对位置,我们可以将锚点放置在屏幕的不同部分,并使该部分成为我们的参考位置。在我们的英雄头像的情况下,我们可以将锚点放置在屏幕的左上角,以确保我们的头像与该角落保持固定距离。
我们可以通过以下步骤来实现这一点:
-
选择你的玩家头像。
-
如果尚未展开,请在 Inspector 中展开 RectTranform 组件,以便可以看到其属性。这将揭示 Scene 视图中的 Anchors。
-
使用鼠标将锚点十字形拖动到屏幕的左上角。如果由于某种原因在拖动锚点时它被分成了几块,撤销更改(按 Ctrl + Z,或在 Mac 上按 Command + Z)然后尝试通过点击中心来拖动它。我们稍后会打破锚点。检查头像图像的 RectTransform 组件,以验证 Anchors 属性的 Min 和 Max 子属性与 图 15.35 中的值相同,这意味着对象已正确配置锚点以位于屏幕的左上角:

图 15.35:屏幕左上角带有锚点的图像
-
将生命条对象及其框架的锚点放置在同一位置。我们希望条始终与该角落保持相同的距离,这样如果屏幕大小改变,它就会随着英雄头像移动。
-
将锚点放置在屏幕的底部中心,以便Boss Bar对象始终居中。稍后,我们将处理调整其大小的问题。
-
将剩余波浪标签放置在左下角,剩余敌人放置在右下角:

图 15.36:生命条和标签的锚点
- 将得分和子弹锚点放置在右上角:

图 15.37:得分和子弹标签的锚点
- 选择任何元素,并用鼠标拖动画布矩形的边缘来预览元素如何适应其位置。请注意,您必须选择任何 Canvas 的直接子对象;按钮内的文本不会有这个选项:

图 15.38:预览画布大小调整
现在我们已经将 UI 元素调整到它们的位置,让我们考虑需要调整对象大小的场景。
调整对象大小
在处理不同的宽高比时,首先要考虑的是,我们的屏幕元素不仅可能从它们原始的设计位置(我们在上一节中固定了它)移动,而且可能无法适应原始设计。在我们的 UI 中,我们有生命条的情况,当我们在更宽的屏幕上预览它时,条显然不会适应屏幕宽度。我们可以通过打破我们的锚点来解决这个问题。
当我们打破锚点时,我们的对象的位置和大小是相对于不同锚点部分的距离来计算的。如果我们水平分割锚点,我们将不会有一个X和宽度属性,而将有一个左和右属性,代表到左右锚点的距离。我们可以用以下方式使用它:
-
选择生命条,并将锚点全部拖动到屏幕的左侧,并将右侧拖动到屏幕的右侧。
-
对生命条框架执行相同的操作:

图 15.39:生命条中的分割锚点
- 在检查器窗口中检查矩形变换设置的左和右属性,它们代表各自锚点的当前距离。如果您想,您可以添加一个特定的值,尤其是如果您的生命条显示在屏幕之外:

图 15.40:分割锚点的左和右属性
这样,对象将始终位于屏幕相对位置的一个固定距离——在这种情况下,屏幕的边缘。如果你正在处理一个子对象,例如按钮的文本和图像组件,锚点相对于父对象。如果你注意文本的锚点,它们不仅水平分割,还垂直分割。这允许文本根据按钮的大小调整其位置,因此你不需要手动更改它:

图 15.41:按钮文本的分割锚点
现在,这个解决方案并不适用于所有场景。让我们考虑一个英雄头像显示的分辨率高于其设计分辨率的案例。即使头像放置正确,由于屏幕每英寸像素数比低分辨率屏幕多,所以它将显示得更小。你考虑使用分割锚点,但宽度和高度锚点在不同宽高比屏幕上可能以不同的方式缩放,因此原始图像会变形。相反,我们可以使用画布缩放器组件。
画布缩放器组件定义了在我们的场景中一个像素代表什么。如果我们的 UI 设计分辨率为 1080p,但我们在一个 4k 显示器上看到它(这是 1080p 分辨率的两倍),我们可以缩放 UI,使一个像素变为 2,以适应其大小,保持与原始设计相同的比例大小。基本上,这个想法是,如果屏幕更大,我们的元素也应该更大。
我们可以通过以下步骤使用此组件:
-
选择画布对象,并在检查器窗口中定位画布缩放器组件。
-
将UI 缩放模式属性设置为按屏幕大小缩放。
-
如果与艺术家合作,将参考分辨率设置为艺术家创建 UI 的分辨率,同时记住它必须是最高目标设备分辨率(对我们来说不是这种情况)。在我们的案例中,我们不确定下载的资产艺术家心中所想的分辨率是什么,因此我们可以设置为1920 x 1080,这是全高清分辨率大小,并且现在非常常见。
-
将匹配属性设置为高度。这个属性的想法是,它设置在执行缩放计算时将考虑哪个分辨率的一侧。在我们的例子中,如果我们以 1080p 分辨率玩游戏,1 个 UI 像素等于 1 个真实屏幕像素。然而,如果我们以 720p 分辨率玩游戏,1 个 UI 像素将是 0.6 个真实像素,因此元素在较小分辨率的屏幕上会变小,保持正确的尺寸。我们没有选择宽度值,因为我们可以在屏幕上有极端的宽度,例如超宽屏幕,如果我们选择了那个选项,那些屏幕会不必要地缩放 UI。另一个选项是将此值设置为
0.5以考虑这两个值,但在 PC 上这没有太多意义。在移动设备上,你应该根据游戏的朝向来选择这个值,为横屏模式设置高度,为竖屏模式设置宽度。 -
尝试预览更宽更高的屏幕,看看这个设置是如何工作的:

图 15.42:适用于标准 PC 游戏的正确设置的画布缩放器
你会发现你的 UI 比原始设计要小,这是因为我们应该在之前设置这些属性。目前,唯一的解决办法是再次调整大小。下次尝试这个练习时请考虑这一点;我们只遵循这个顺序是为了学习目的。
带着这些知识,你现在可以开始编写脚本,以反映游戏中的情况。
编写 UI 脚本
我们之前创建了一个包含条、文本和按钮等元素的 UI 布局,但到目前为止,它们是静态的。我们需要让它们适应游戏的实际状态。在本节中,我们将讨论以下 UI 脚本概念:
-
在 UI 中显示信息
-
编程暂停菜单
我们将首先看看如何使用脚本在 UI 上显示信息,这些脚本通过修改 Canvas 元素显示的文本和图像来实现。之后,我们将创建暂停功能,该功能将在整个 UI 中使用。
在 UI 中显示信息
如前所述,我们将使用 UI 向用户显示信息,以便他们可以做出明智的决定,所以让我们先看看我们如何使玩家在Life脚本中创建的生命条对剩余的生命量做出反应:
- 将名为生命条的新脚本添加到HealthBar画布子对象中,这是我们之前创建的 UI
Image组件,用于表示生命条:
图 15.43:玩家健康条画布中的生命条组件
- 在
LifeBar中,脚本添加了一个Life类型字段。这样,我们的脚本将询问编辑器我们将监控哪个Life组件。保存脚本:

图 15.44:编辑器可配置的生命组件引用
- 在编辑器中,从层次结构窗口拖动
PlayerGameObject 到targetLife属性,使生命条引用玩家的生命,并记得在拖动Player之前选择HealthBar对象。这样,我们就告诉了LifeBar脚本要检查哪个生命组件以查看玩家剩余的生命。这里有趣的是,敌人也有相同的生命组件,因此我们可以轻松地使用这个组件为游戏中每个有生命的其他对象创建生命条:

图 15.45:拖动玩家以引用其生命组件
-
在脚本的前几行
using语句之后添加using UnityEngine.UI;行。这将告诉 C#我们将与 UI 脚本进行交互:![图片]()
图 15.46:我们脚本中的所有
using语句。我们不会使用它们全部,但现在让我们保留它们 -
创建一个
private类型的Image字段。我们将在稍后在这里保存组件的引用:

图 15.47:对图像的私有引用
- 在
Awake中使用GetComponent获取我们 GameObject(HealthBar)中Image组件的引用,并将其保存在image字段中。通常的想法是只获取这个引用一次,并在Update函数中稍后使用它。当然,当你把这个组件放在一个带有Image组件的对象中时,这总是可行的。如果不是这样,另一个选择是创建一个Image类型的公共字段,并将图像组件拖放到它里面:

图 15.48:在这个对象中保存 Image 组件的引用
-
在
LifeBar脚本中创建一个Update事件函数。我们将使用它来根据玩家的生命不断更新生命条。 -
在
Update事件中,将生命值除以100,以便以0到1的范围(假设最大生命值为100)表示当前生命百分比,并将结果设置在Image组件的fillAmount字段中,如以下截图所示。记住,fillAmount期望一个介于0和1之间的值,其中0表示条形是空的,1表示条形已满:

图 15.49:根据生命组件更新 LifeBar 脚本 Image 组件的填充量
记住,在代码中将100放入其中被认为是硬编码(它也被称为魔法数字),这意味着对该值的后续更改将需要我们查找代码中的该值,这在大型项目中是一项复杂的任务。这就是为什么被认为是不良做法。更好的做法是在生命组件中有一个最大生命字段,或者至少有一个具有该值的常量。
- 保存脚本,并在编辑器中选择玩家并开始游戏。在播放模式下,按Esc键以恢复鼠标访问权限,并在检查器窗口中将玩家的健康值更改以查看生命条如何相应更新。您也可以通过让玩家以某种方式受到伤害来测试此功能,例如,通过让敌人发射子弹(关于敌人的更多内容将在后面介绍):

图 15.50:完整生命条脚本
在上一章中,我们探讨了事件的概念,用于检测其他对象状态的变化。生命条是使用事件作为另一个例子,因为我们可以在生命实际变化时更改图像的填充量。我挑战你尝试在生命变化时创建一个事件,并使用我们在上一章中查看的脚本实现此脚本。
你可能认为这种 UI 行为可以直接在Life组件中编码,这是完全可能的,但这里的想法是创建简单的脚本,压力小,以保持我们的代码分离。每个脚本应该只有一个修改的理由,将 UI 行为和游戏玩法行为混合在单个脚本中会给脚本增加两个责任,从而导致两个可能改变我们脚本的原因。采用这种方法,我们还可以通过将相同的脚本添加到生命条中,但这次将我们在上一章中创建的基础伤害对象作为目标生命,来在底部设置玩家的基础生命条。
关于视觉脚本版本,以下是你需要添加到你的健康条图像游戏对象中的内容:

图 15.51:完整生命条视觉图形
首先,我们在生命条图像的变量组件中添加了一个类型为GameObject的targetLife变量。然后,我们将我们的Player游戏对象(到目前为止称为Robot)拖动到这个变量上,这样生命条现在就有了我们想要显示其生命的对象的引用。然后我们添加了一个LifeBar视觉图形;在更新节点中,它调用设置填充量节点以更新图像的填充量。记住,在这种情况下,仅调用设置填充量节点就会理解我们指的是位于此视觉图形中的图像组件,因此在这里不需要使用GetComponent。为了计算填充量,我们获取targetLife游戏对象引用,并使用第二个获取变量节点提取该对象的生命变量。最后,我们将该值除以 100(我们需要创建一个浮点字面量节点来表示值100)并将其传递给设置填充量节点。像往常一样,您可以在 GitHub 仓库中查看完整版本。
我们刚才提到的单一对象职责原则是被称为 SOLID 的五个面向对象编程原则之一。如果您不知道 SOLID 是什么,我强烈建议您在网上搜索 SOLID 编程原则 以提高您的编程最佳实践。
现在我们已经整理好了玩家的生命条,让我们让 Bullets 标签根据玩家的剩余子弹更新。这里需要考虑的是,我们当前的 PlayerShooting 脚本具有无限子弹,所以让我们通过以下步骤来改变这一点:
-
在
PlayerShooting脚本中添加一个名为bulletsAmount的publicint类型字段。 -
在检查左鼠标按钮压力的
if语句中,添加一个条件来检查子弹数量是否大于0。 -
在
if语句中,子弹数量减少1:

图 15.52:限制射击的子弹数量
在视觉脚本版本中,PlayerShooting 视觉图的修改后的射击条件将如下所示:

图 15.53:只有在有子弹可用时射击,并在射击后减少子弹数量
如您所见,我们只需检查我们添加的新 bullets 变量是否大于零,然后使用 If 节点条件来执行 Instantiate 节点。至于子弹的减少,它将如下所示:

图 15.54:在视觉图形中减少子弹计数
我们只需从子弹变量中减去一个值,然后使用这个值重新设置子弹。
现在我们有一个表示剩余子弹数量的字段,我们可以通过以下方式创建一个脚本,在 UI 中显示这个数字:
-
将
PlayerBulletsUI脚本添加到子弹的Text游戏对象中。在我的例子中,我将其命名为Bullets Label。 -
在文件开头添加
using TMPro;语句,因为我们将会修改标签的Text Mesh Pro组件。 -
在
Awake中添加一个private字段,类型为TMP_Text,保存我们自己的Text组件的引用:

图 15.55:缓存我们自己的 Text 组件的引用
-
在编辑器中将
PlayerShooting类型的public字段targetShooting创建出来,并将Player拖动到这个属性上。与LifeBar组件的情况一样,我们的想法是 UI 脚本将访问拥有剩余子弹的脚本以更新文本,将两个脚本(Text和PlayerShooting)连接起来以保持它们职责的分离。 -
创建一个
Update语句,并在其中设置文本引用的text字段(我知道,有点令人困惑),使用"Bullets: "和targetShooting引用的bulletsAmount字段的连接。这样,我们将根据当前子弹数量替换标签的文本:

图 15.56:更新子弹的文本标签
记住,字符串连接会分配内存,所以再次建议您只在必要时使用事件来执行此操作。同时考虑使用两个分开的标签,一个用于"Bullets: "部分,另一个仅用于子弹数量,这样您就可以只更改数字标签,避免连接和 UI 文本重生的成本。
关于视觉脚本,在实际设置文本之前,我们需要在视觉脚本中添加对 TextMeshPro 的支持。视觉脚本需要手动指定我们将要使用的 Unity 系统和包,由于 TextMeshPro 不是严格意义上的 Unity 核心功能,因此它可能默认不包含。我们可以通过以下方式在视觉脚本中添加对 TextMeshPro 的支持:
-
前往编辑 | 项目设置并选择视觉脚本类别。
-
使用左边的箭头展开节点库选项。
-
检查列表中是否有Unity.TextMeshPro。如果有,您可以自由跳过这些步骤。
-
使用列表底部的+按钮添加一个新的库。
-
点击显示(无程序集)的地方,搜索Unity.TextMeshPro。
-
点击重新生成节点按钮,等待生成过程完成:

图 15.57:向视觉脚本添加 TextMeshPro 支持
设置完成后,添加到子弹文本 GameObject 的视觉图形将如下所示:

图 15.58:在视觉脚本中更新子弹的文本标签
如同往常,我们需要一个玩家的引用来检查其子弹,因此我们创建了一个类型为GameObject的targetBullets变量,并将其拖到那里。然后我们使用获取变量节点从该引用中提取子弹数量,并使用字符串字面量节点和连接节点将字符串"Bullets: "与子弹数量连接起来。该节点将执行与我们在 C#中使用+运算符连接两个字符串相同的功能。最后,我们使用设置文本(源文本,同步文本输入框)节点来更新文本字段的文本。
如果您查看这两个脚本,您会发现一个模式。您可以访问UI和Gameplay组件,并相应地更新UI组件,大多数 UI 脚本的行为方式相同。记住这一点,我挑战您创建必要的脚本,使Score、Enemies和Waves计数器工作。请记住添加using TMPro;以使用TMP_Text组件。完成此操作后,您可以比较以下截图中的解决方案,从ScoreUI开始:

图 15.59:ScoreUI 脚本
此外,我们还需要WavesUI组件:

图 15.60:WavesUI 脚本
最后,我们需要EnemiesUI:

图 15.61:EnemiesUI 脚本
注意我们如何利用WavesManager和EnemyManager脚本中存在的onChanged事件,仅在需要时更新文本字段。观察我们如何不需要拖动引用来获取要显示的值,因为这些脚本都使用管理器来获取这些信息。
关于视觉脚本,我们有ScoreUI脚本:

图 15.62:ScoreUI 视觉脚本
然后是WavesUI脚本:

图 15.63:Waves UI 视觉脚本
最后,是EnemiesUI脚本:

图 15.64:Enemies UI 视觉脚本
如您所见,我们已使用管理器中已编写的事件,仅在必要时更改 UI。此外,注意我们如何使用Scene变量来获取要显示的信息。现在我们已经编写了 UI 标签和条,让我们编写Pause菜单。
编程暂停菜单
回想一下我们如何在之前的章节中创建了一个暂停菜单。它目前是禁用的,所以让我们让它工作。首先,我们需要编写暂停功能,这可能相当复杂。所以,我们再次将使用一个简单的方法来暂停大多数行为,即停止时间!记住,我们的大多数移动脚本都使用时间功能,如Delta Time(我们在第二章,编辑场景和游戏对象中讨论过的),作为计算要应用的运动量的方式。还有一种方法可以模拟时间变慢或变快,即通过设置timeScale。这个字段将影响 Unity 的时间系统速度,我们可以将其设置为0来模拟时间已停止,这将暂停动画,停止粒子,并将Delta Time减少到0,使我们的运动停止。所以,让我们这样做:
-
创建一个名为
Pause的脚本并将其添加到一个名为Pause的新 GameObject 中。 -
在脚本文件开头添加
using UnityEngine.InputSystem;语句,以便能够读取输入。 -
在
Update中检测是否按下了Esc键。我们可以将映射添加到我们的玩家输入资产文件中,并像在第二章,编辑场景和游戏对象中做的那样读取输入,但为了学习使用输入系统的新方法,我们将使用Keyboard.current变量直接在Update方法中读取键的状态,而不是使用映射。请注意,始终推荐使用输入映射,但为了学习目的,我们这样做。当按下Esc键时,可以将Time.timeScale变量设置为0,如下图中所示:

图 15.65:停止时间以模拟暂停
- 通过玩游戏并按下Esc键来保存并测试此脚本。你会注意到几乎一切都会停止,但你仍然可以看到射击功能仍然在正常工作。这是因为
PlayerShooting脚本不依赖于时间。这里的一个解决方案是简单地检查Time.timeScale是否大于0以防止这种情况:

图 15.66:检查玩家射击脚本中的暂停
- 在我们的
EnemyFSM射击方法中也需要做同样的操作,将其改为以下内容:
如往常一样,我们在这里追求最简单的方法,但有一个更好的方法。我挑战你尝试创建一个带有布尔值的PauseManager,该布尔值指示游戏是否已暂停,并在过程中更改timeScale。
现在我们已经有一个简单但有效的方法来暂停游戏,让我们通过以下步骤使暂停菜单可见以恢复游戏:
-
在
Pause脚本中添加一个名为pauseMenu的GameObject类型的字段。想法是将暂停菜单拖到这里,以便我们有一个引用来启用和禁用它 -
在
Awake状态下,添加pauseMenu.SetActive(false);以在游戏开始时禁用Pause菜单。即使我们在编辑器中禁用了Pause菜单,我们也添加这个代码以防我们不小心重新启用它。它必须始终处于禁用状态。 -
使用相同的函数,但将
true作为第一个参数传递,在Esc键压力检查中启用Pause菜单:

图 15.67:按下 Esc 键时启用暂停菜单
现在,我们需要使Pause菜单按钮正常工作。如果您还记得,我们探讨了事件的概念,通过在不同的Managers中使用UnityEvents来实现。我们的Pause菜单按钮使用相同的类来实现onClick事件,这是一个通知我们特定按钮已被按下的事件。让我们通过按下这些按钮来恢复游戏,操作如下:
-
在我们的Pause脚本中创建一个名为
resumeButton的Button类型字段,并将resumeButton拖放到其中;这样,我们的Pause脚本就有一个对按钮的引用。 -
在
Awake状态下,将名为OnResumePressed的监听函数添加到resumeButton的onClick事件中。 -
使
OnResumePressed函数将timeScale设置为1并禁用Pause菜单,就像我们在Awake中做的那样:

图 15.68:取消暂停游戏
如果你保存并测试这段代码,你会注意到你不能点击Resume按钮,因为我们已经在游戏开始时禁用了光标,所以请确保你在Pause状态下重新启用它,并在恢复时禁用它:

图 15.69:在暂停时显示和隐藏光标
最后要考虑的一点是我们希望在OnDestroy方法中将时间比例重新设置为1。当Pause对象被销毁时,这个方法会被执行,这通常发生在我们通过脚本手动销毁对象时,或者在这个案例中,最重要的是当我们更改场景时。我们的想法是确保在Pause菜单中更改场景时恢复时间系统,这样下一个场景可以正确地播放游戏:

图 15.70:离开场景时重置时间比例
关于Pause脚本的 Visual Scripting 版本,请注意我们没有Keyboard.current的等价物,所以我们需要使用输入映射来实现。为了为Esc键添加输入映射,请执行以下操作:
-
双击Player Input资产进行编辑。您可以通过选择PlayerGameObject,然后点击检查器中
PlayerInput组件的Actions属性右侧的框来找到它。 -
使用动作列表(中间列表)右上角的+按钮创建一个新的动作,命名为
暂停:

图 15.71:创建新的输入映射
-
点击我们刚刚创建的暂停动作中的<无绑定>项(在其下方)。
-
在绑定属性部分的路径属性(屏幕右侧),点击其左侧的空矩形,并搜索并选择Escape [键盘]按钮:

图 15.72:将键添加到映射
- 点击屏幕顶部中间的保存资产按钮。
现在,你可以添加以下图,这次是添加到玩家GameObject 中,因为我们需要从它读取输入:
![img/B18585_15_73.png]
图 15.73:按下 Esc 键时暂停
到目前为止没有什么新东西;我们检测到按下了Esc键,在这样的时刻,我们调用设置时间缩放并指定0值。然后我们激活暂停菜单(通过变量组件中的变量pauseMenu引用),并启用光标。最后,当对象被销毁时,我们将时间缩放设置为1。
关于继续的行为,需要添加到相同暂停图中的节点将看起来像这样:

图 15.74:按下继续按钮时取消暂停
图中的唯一新元素是On Button Click节点的使用。正如你所期望的,该节点是一个事件,任何连接到它的内容都会在按钮按下时执行。指定我们指的是哪个按钮的方法是通过将Button引用变量连接到On Button Click的输入引脚。你可以在变量组件中看到我们如何创建了一个名为resumeButton的Button类型的变量来完成这个操作。
现在你已经知道了如何编写按钮的代码,我挑战你编写退出按钮的行为。再次提醒,记得添加using UnityEngine.UI。此外,你需要调用Application.Quit();来退出游戏,但请注意,在编辑器中这不会起作用;我们不想在创建游戏时关闭编辑器。这个函数只有在构建游戏时才会生效。
因此,现在只需调用它即可,如果你想打印一条消息以确保按钮正常工作,你可以这样做;以下截图提供了一个解决方案:

图 15.75:退出按钮脚本
本解决方案建议您将此脚本直接添加到退出按钮的 GameObject 本身,以便脚本监听其Button兄弟组件上的onClick事件,并在那种情况下执行Quit函数。您也可以将此行为添加到Pause脚本中,虽然这样也可以工作,但请记住,如果一个脚本可以分成两个,因为它执行两个不相关的任务,那么最好将其拆分,以便分离的行为不相关。在这里,暂停行为与退出行为不相关。
关于视觉脚本版本,要添加到退出按钮的图看起来像这样:

图 15.76:退出按钮的视觉脚本
简单,对吧?因为我们将其放入Button本身,所以我们甚至不需要指定哪个按钮,因为它会自动检测我们是在指自己。
现在我们已经使用 UI 和按钮设置了暂停系统,让我们继续探讨其他视觉和听觉方式,让玩家意识到发生了什么。
摘要
在本章中,我们介绍了 UI 的基础知识,理解Canvas和RectTransform组件以在屏幕上定位对象并创建 UI 布局。我们还介绍了不同类型的 UI 元素,主要是图像和文本,以使我们的 UI 布局生动,并吸引用户。最后,我们讨论了如何调整 UI 对象以适应不同的分辨率和宽高比,使我们的 UI 能够适应不同的屏幕尺寸,尽管我们无法预测用户将使用的确切显示器。所有这些使我们能够使用 Canvas 创建我们游戏中需要的任何 UI。
在下一章中,我们将探讨如何使用 UI Toolkit 来创建 UI,这是 Unity 系统中另一种创建 UI 的方法,并将 Canvas 和 UI Toolkit 进行比较,以确定在何处使用哪种。
加入我们的 Discord 社区!
与其他用户、Unity 游戏开发专家和作者本人一起阅读此书。
提出问题,为其他读者提供解决方案,通过 Ask Me Anything(问我任何问题)环节与作者聊天,等等。
扫描二维码或访问链接以加入社区。

packt.link/handsonunity22
第十六章:使用 UI Toolkit 创建 UI
在上一章中,我们讨论了如何使用 uGUI(也称为 Canvas),这是最常用的 Unity UI 系统之一,但正如我们已经提到的,这并不是唯一的选择。虽然到目前为止,uGUI 是最受欢迎的选项,但 Unity 正在开发一个名为 UI Toolkit 的替代品,即使它还没有与 uGUI 具有相同的功能,我们认为在本书中介绍它仍然是有价值的。
本章的目的是创建之前创建的相同 UI,但使用 UI Toolkit,这样你可以了解在 Unity 中创建 UI 将会是什么样子。
在本章中,我们将探讨以下 UI 概念:
-
为什么学习 UI Toolkit?
-
使用 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 元素的资产。为此,我们将讨论以下概念:
-
创建 UI 文档
-
编辑 UI 文档
-
创建 UI 样式表
让我们先看看我们如何创建我们的第一个 UI 文档。
创建 UI 文档
当使用 uGUI 创建 UI 时,我们需要创建 GameObject 并附加按钮、图像或文本等组件,但使用 UI Toolkit 时,我们需要创建一个UI 文档。UI 文档是一种特殊的资产,它将包含我们的 UI 将具有的定义及其层次结构。我们将有一个带有UI 文档组件的 GameObject(是的,它叫法相同,所以请注意这里),它将引用这个 UI 文档资产并渲染其内容。它就像一个包含有关 Mesh 信息的资产,以及将渲染它的MeshRenderer组件。在这种情况下,要渲染的元素包含在一个资产中,我们有一个读取资产并渲染其内容(在这种情况下是 UI)的组件。
UI 文档实际上是纯文本文件。你可以用文本编辑器打开它,并轻松查看其内容。如果你这样做,并且熟悉 HTML,你会认出用于定义我们的 UI 将包含的元素的 XML-like 格式;Unity 称这种格式为UXML。使用 UI Toolkit,Unity 试图让网页开发者更容易地进入 Unity 并创建 UI。在下面的代码中,你可以看到 UXML 文档文件内容的典型外观:
<ui:UXML
xmlns:ui="UnityEngine.UIElements"
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 构建器的可视编辑器来编辑我们的 UI,而无需编写任何 UXML,但了解它是如何实际工作的还是有价值的。
为了创建 UI 文档并将其添加到场景中,我们需要执行以下操作:
- 在项目视图中点击+ | UI 工具包 | UI 文档选项来创建一个 UI 文档资产,并将其命名为
GameHUD:

图 16.1:创建 UI 文档资产
-
点击游戏对象 | UI 工具包 | UI 文档选项,在场景中创建一个带有 UI 文档组件的 GameObject,该组件能够渲染 UI 文档。
-
选择它,并将GameHUD UI 文档资产(在步骤 1中创建的)拖放到 UI 文档 GameObject 的源资产属性中(在步骤 2中创建的):

图 16.2:使 UI 文档组件渲染我们的 UI 文档资产
就这样!当然,由于 UI 文档是空的,我们屏幕上目前什么也看不到,所以让我们开始向其中添加元素。
编辑 UI 文档
由于我们的目标是重新创建上一章中创建的相同 UI,让我们从最简单的一部分开始:将玩家头像添加到左上角。一个选择是使用任何文本编辑器打开 UI 文档资产并开始编写 UXML 代码,但幸运的是,我们有一个更简单的方法,那就是使用UI 构建器编辑器。这个编辑器允许我们通过拖放元素来可视化地生成 UXML 代码。
为了做到这一点,让我们首先看看UI 构建器窗口是如何工作的:
- 双击项目视图中的GameHUD资产以打开UI Builder:

图 16.3:UI Builder 编辑器
- 在 UI Builder 内部的层次结构面板中(不是我们在前几章中使用的层次结构面板),选择
GameHUD.uxml,这是 UI 的容器元素。

图 16.4:在层次结构中选择资产名称以编辑通用 UI 设置
- 查看 UI Builder 窗口右侧的检查器面板(不是我们之前用来修改 GameObject 的检查器)。将大小属性设置为宽度为
1920和高度为1080。这将允许我们查看我们的 UI 在这个分辨率下的外观。您稍后可以更改此值以查看它如何适应不同的大小,但关于这一点我们稍后再说:

图 16.5:设置预览 UI 分辨率
-
您可以通过按鼠标滚轮按钮(也称为中间按钮)并移动鼠标来平移视口以导航 UI。在 Mac 上,您还可以按Option + Command并点击并拖动视口的任何自由区域(没有我们的 UI 的地方)来完成相同的操作。
-
您还可以使用鼠标滚轮来放大和缩小。最后,您可以使用视口左上角的缩放百分比选择和适应画布按钮自动将整个 UI 适应到您的视口中:

图 16.6:设置预览缩放
现在我们已经了解了 UI Builder 的基础知识,让我们将我们的图片添加到 UI 中:
- 从左下角的库中将VisualElement图标拖到左边的层次结构部分。这将创建一个基本的 UI 元素,可以渲染图像以及更多内容:

图 16.7:创建视觉元素
-
在层次结构(在
GameHUD.uxml下)中选择VisualElement,并查看 UI Builder 窗口右侧的检查器(不是我们之前用来修改 GameObject 的常规 Unity 检查器面板)中的位置部分。如果尚未展开,请展开它(使用左侧的箭头)。 -
将位置设置为绝对,以便我们可以自由地在 UI 中移动我们的元素。在本章的后续部分,在使用相对位置部分,我们将解释相对模式是如何工作的:

图 16.8:设置我们的 UI 元素可以自由移动
- 打开大小部分,将宽度和高度设置为
100,使我们的 UI 元素具有非零大小。这样,我们就可以在视口中看到其区域:

图 16.9:设置我们的 UI 元素大小
- 在视口面板中,您可以拖动您的元素并使用角落的蓝色矩形来更改其大小。将您的元素放置在 UI 的左上角。如果您在视口中看不到您的元素,请在层次结构(UI Builder 的那个)中选择它:

图 16.10:移动 VisualElements
- 为了设置精确的位置,您可以在检查器的位置部分的左和上值中设置,以分别指定精确的x和y坐标,以像素为单位:

图 16.11:设置位置
-
在检查器的背景部分,使用图像属性右侧的组合框将图像模式设置为精灵。这允许我们将精灵作为元素的背景应用。
-
将我们在第十五章“用户界面设计”中导入的玩家头像的精灵资产(图片)从项目面板拖动到图像属性,以设置它。此外,您还可以使用目标按钮(中间带点的圆形按钮)从选择窗口中选择精灵资产:

图 16.12:设置元素的背景图片
- 返回常规的游戏面板查看结果。如果您没有看到变化,您可以关闭并重新打开渲染我们的 UI(使用 UI 文档创建的那个)的 GameObject。
现在我们已经创建了玩家头像,我们可以通过以下步骤创建玩家生命条:
-
重复之前的步骤1 到6,创建一个新的元素,该元素将作为玩家生命条容器。它将没有任何图像,它只是其他将组成生命条的元素容器。
-
将其放置在玩家头像的旁边,并设置宽度和高度以类似于经典的生命条。记住,您可以通过拖动图像和角落的方块,或者通过大小和位置属性来完成此操作,就像我们之前所做的那样。
-
按照第 1 步,将一个新的视觉元素拖动到层次结构中,但这次将其拖动到第 1 步中创建的元素上。这将使这个新元素成为其子元素,这将使该元素的位置和大小取决于其父元素,就像我们在第十五章“用户界面设计”中父化 Canvas 对象时发生的那样。
-
选择父级视觉元素,并在检查器中设置名称属性为
PlayerHealth,以便轻松识别。对子元素也执行相同的操作,将其命名为Filling:

图 16.13:父化和命名视觉元素
-
在层次结构中选择Filling元素,并查看检查器。
-
在背景部分,将颜色属性设置为红色,点击颜色框并使用颜色选择器。这将用纯红色填充我们的 UI 元素背景,而不是使用图像:

图 16.14:为我们的元素设置纯红色背景
-
如往常一样,将位置设置为绝对,并将左和顶属性设置为
0。由于这是一个元素的子元素,其位置将相对于其父元素的位置,因此通过指定左和顶值为0,我们表示我们将位于父元素的左侧和顶部 0 像素处。这意味着如果父元素移动,这个子元素将随之一同移动。 -
将大小的宽度和高度设置为
100,并通过单击px按钮并选择%来更改度量单位从px(像素)到%(百分比)。这将使填充元素的大小与其父元素相同(100%的父元素大小):

图 16.15:将我们的大小设置为与父元素相同
-
将一个新的VisualElement作为PlayerHealth(填充的兄弟元素)的子元素添加,并命名为
Border。 -
将位置和大小设置为我们在步骤7和8中为填充元素所做的设置,但不要设置背景颜色。
-
将背景部分的图像属性设置为我们在上一章中使用的相同边框图像。请记住将图像模式设置为精灵而不是纹理。
-
在背景部分设置切片属性为
15。这应用了我们在第十五章,用户界面设计中使用的九宫格技术,以在不拉伸对象的情况下扩展对象:

图 16.16:在元素中直接设置九宫格大小
- 在层次结构中选择填充视觉元素,并设置其大小部分的宽度属性以模拟我们在第十一章,用户界面设计中使用的图像的填充量属性。稍后,我们将通过代码将此大小直接与玩家的健康数值成比例:

图 16.17:健康条结果
- 重复步骤1到12以创建基础健康条的底部。记住这次填充必须是绿色。或者,您也可以直接复制并粘贴PlayerHealth容器,但我建议您为了学习目的重复这些步骤。
在前面的步骤中,我们基本上看到了如何组合几个 UI 元素来创建一个复杂对象。我们需要一个父容器元素来驱动子元素的大小,以便内部元素适应它,特别是填充,它需要一个百分比值来表示当前玩家的健康。
现在我们有了我们的生命条!嗯,还不完全是这样;填充的红色角落,我们的边框没有覆盖,看起来相当粗糙!我们将在本章后面讨论如何使我们的 UI 响应时改进这一点,所以现在让我们保持原样。
最后,让我们通过以下步骤将文本元素添加到 UI 中,但首先,我们需要考虑字体。如果你下载了 TTF 字体,你需要创建一个字体资产,就像我们在第十五章,用户界面设计中做的那样,以便在 UI 工具包中使用。但是,根据当前 UI 工具包的版本,我们在上一章中创建的字体资产不兼容。我们需要使用 UI 工具包字体资产创建器来创建字体资产,而不是使用 Text Mesh Pro。存在重复工具的原因是 Unity 正在将 Text Mesh Pro 包集成到一个新的、改进的包中,称为 Text Core,其中一项改进是与 UI 工具包和其他 Unity 系统的兼容性。
考虑到这一点,为了将 TTF 转换为与 UI 工具包兼容的字体资产,你只需在项目面板中右键单击 TTF 资产,然后选择创建 | 文本 | 字体资产。这将创建一个新的资产,我们将使用它来定义 UI 工具包文本的字体。
解决了这个问题后,让我们创建一个用于文本的 UI 元素,即标签:
-
将 UI 建造窗口的库面板中的标签图标拖动到其层次结构面板。这将添加一个 UI 元素,它不仅能够在其背景中渲染图像,还可以渲染文本(是的,如果你想的话,你可以给文本添加背景)。
-
如同往常,设置其位置和大小,这次将其放置在屏幕的右上角。记住你可以简单地拖动元素;你不需要手动设置特定的坐标(尽管如果你想的话也可以)。
-
将检查器中标签部分的文本属性更改为所需的文本;在我们的例子中,这将是一个
得分:0:

图 16.18:设置要显示的文本
-
将这些步骤之前创建的字体资产拖动到检查器中文本部分的字体资产属性。不要将其与字体属性(位于字体资产之上)混淆。那个属性允许你直接拖动 TTF 资产,但这个功能很快就会过时,所以让我们坚持使用 Unity 推荐的方法。
-
如果你注意到你的字体资产不起作用,尝试将其放入项目面板中的UI Toolkit | 资源 | 字体和材质文件夹。虽然这不应该在最新的 Unity 版本中是必要的,但我注意到过去这解决了这类问题。此外,有一个错误有时会导致字体不被识别,可以通过删除并重新创建标签来修复。
-
将文本部分的大小属性设置为任何合适的大小:

图 16.19:设置标签的文本字体和大小
-
重复步骤 1到6,将所有剩余的标签添加到 UI 中。
-
我们需要做的最后一件事是保存,这可以通过按Ctrl + S(在 Mac 上为Command + S)或使用UI Builder窗口顶部的视图部分左上角的文件 | 保存菜单来完成。请注意,UI Toolkit 的早期版本中存在一个错误,这可能会导致视图损坏。如果发生这种情况,请关闭它并重新打开 UI Builder。
现在我们已经创建了 UI,你可能已经注意到需要重复设置以使多个对象看起来相同,比如我们的生命条和标签。虽然这是完全可行的,但通过重用样式,我们可以极大地提高我们的工作流程,而样式表正是我们完成这一目标所需的确切功能,所以让我们来看看它们。
创建 UI 样式表
在创建 UI 时,你可能会遇到整个游戏中多个元素共享相同样式的场景,例如具有相同背景、字体、大小、边框等的按钮。当使用 uGUI 创建 UI 时,避免为每个元素重复配置的一种方法是为按钮创建一个 Prefab 并创建实例(以及必要时创建 Prefab 变体)。问题是这里我们没有 GameObject,因此没有 Prefab,但幸运的是,我们有样式表。
样式表是包含一系列针对我们 UI 元素样式预设的独立资产。我们可以定义一组样式(例如,背景、边框、字体、大小等)并将这些样式应用到不同 UI 元素中的多个元素上。这样,如果我们更改样式表资产中的样式,使用该样式的所有 UI 元素都会相应改变,这与材质的工作方式类似。
在样式表中创建样式有几种方法。一个例子是选择器系统。这个系统允许你应用一系列规则来选择哪些元素应该应用样式(如果你认为这类似于 CSS,那么你是正确的),但让我们现在先从基础开始,创建样式表类。类基本上是一种可以通过其名称应用到任何元素上的样式。例如,我们可以创建一个名为Button的类,并将该类添加到 UI 中我们希望应用该样式的每个按钮上。请考虑,这里的类概念与编程中的类概念完全不同。
因此,在这种情况下,让我们为 UI 中的所有标签创建一个类,这样只需更改样式就可以修改它们的外观:
- 在UI Builder的样式表面板中,点击添加(+)按钮,然后点击创建新 USS(Unity StyleSheet)。如果不起作用,请尝试重新启动 Unity;当前版本的 UI Toolkit 中存在一个错误,可能会导致此问题:

图 16.20:创建 Unity StyleSheet
-
将 USS 命名为你喜欢的名称(例如,我的情况是
GameUSS)并保存文件。 -
在我们的 UI 文档中选择一个标签元素,并查看检查器。
-
在检查器的样式表面板中,在样式类列表输入字段中键入
HUDText,但不要按Enter。 -
点击将内联样式提取到新类按钮。这将把我们为标签(位置、大小、字体等)所做的所有样式修改保存到一个名为
HUDText的新样式类中。你可以观察到它被添加到元素应用的类列表中(那些在检查器中样式表部分底部的标签):

图 16.21:将设置提取到样式类
通过这些步骤,我们已经将具有我们需要应用到其他元素上的样式的标签提取到一个名为HUDText的类中。这样,我们只需将HUDText类添加到我们的 UI 中的其他元素,甚至可以将相同的 USS 资产添加到其他 UI 文档中(在样式表面板上点击+按钮 | 添加现有 USS)以将此类添加到其中的元素。
此外,如果你再次选择标签,你可以注意到之前加粗的属性现在又变回了正常;这是因为加粗的属性表示已更改的属性,我们已经提取了它们,因此默认值变成了样式类定义的任何值。幸运的是,并不是所有内容都被提取到新的 USS 类中;例如,文本字段仍然有我们特定的所需文本,因为你不太可能希望在其他对象中放入相同的文本。

图 16.22:文本属性加粗,表示它与默认值不同。另一方面,启用富文本没有加粗,这意味着它遵循默认值和类值
如果你在提取类时忘记了某些样式更改,你可以在UI Builder的右上部分选择样式表部分,然后选择列表中的HUDText类。如果你看不到它,尝试展开GameUSS.uss部分。
一旦选择,你可以在检查器面板中更改它,类似于我们更改 UI 元素的属性:

图 16.23:选择样式类进行修改
这样,我们就编辑了我们的HUDText类。如果其他元素应用了此类,它们也会应用这些更改。考虑另一种选项是首先创建类,在样式表输入字段中键入名称并按Enter,然后将其应用到 UI 元素上。这样,你将避免需要撤销不希望的变化,但如果首先创建了元素,则方便有撤销选项:

图 16.24:从头创建样式类
现在我们有了我们的样式类,让我们通过以下步骤将其应用到其他元素上:
-
选择我们的 UI 中的另一个标签。
-
将HUDText样式从 UI 构建器窗口左上角的样式表面板拖动到视口上的元素。你也可以选择将其拖动到层次结构元素:

图 16.25:将类应用到元素
- 选择标签并检查HUDText类是否已添加到检查器上的样式表部分。
现在,考虑即使元素现在应用了类,元素本身对我们在之前步骤中做的文本更改也有影响,覆盖了我们类中的样式。你可以通过再次选择类(在UI 构建器窗口左上角的样式表部分)并更改任何设置,如大小,来轻松检查这一点,看看不是所有元素都发生了变化。这显示了覆盖系统是如何工作的;元素上的更改优先于它所应用的类中的更改。
如果你想移除这些覆盖,你可以简单地选择元素(不是类),在覆盖的属性上右键点击,然后通过右键点击并选择取消设置来撤销更改。在我们的标签例子中,我们可以取消整个文本部分以及可能的全局绝对位置(因为期望的值已经包含在类中)。

图 16.26:撤销覆盖以使用应用于元素的类的默认值
因此,通过这些步骤,我们创建了一个新的样式表资产,并将其添加到 UI 文档中以便使用。我们在其中创建了一个新的样式类,将现有 UI 元素的更改提取到其中,然后调整我们想要保留的更改。最后,我们将这种样式应用到另一个元素上。通过这种方式,我们只是触及了样式表的真正力量。我们可以开始做一些事情,比如从不同的样式表中组合不同的类,或者使用选择器动态设置样式,但这超出了本章的范围。
有趣的是,尽管 UI 工具包的文档目前相当基础,但所有这些高级概念都可以通过阅读 CSS 来学习,这是 Unity 基于样式表系统的基础技术。它不会完全相同,但基本思想和最佳实践仍然适用。
现在,UI 看起来几乎与第十五章“用户界面设计”中的样子完全一样,但它不会以相同的方式表现。如果你尝试更改视口的大小(在层次结构中选择GameHUD.uxml并更改宽度和高度,就像我们在本章开头做的那样),你会看到 UI 不会正确适应,所以让我们来修复这个问题。
制作响应式 UI
在本节中,我们将学习如何使之前创建的 UI 适应不同的屏幕尺寸。我们将讨论以下概念:
-
动态定位和尺寸
-
动态缩放
-
使用相对位置
让我们先讨论一下我们如何使我们的对象的位置和大小适应屏幕大小。
动态定位和大小
到目前为止,我们已经使用了左和顶位置属性来指定我们的元素相对于屏幕左上角的位置,然后使用宽度和高度来定义大小。虽然本质上这就是定义一个对象位置和大小的全部所需,但在所有情况下它并不非常实用,尤其是在我们需要适应不同屏幕尺寸时。
例如,如果您需要将一个对象放置在屏幕的右上角,其大小为100x100像素,屏幕大小为1920x1080像素,我们可以将左和右位置属性设置为1820x980像素,这将有效,但仅适用于该特定分辨率。
那么,如果用户以1280x720像素运行游戏会发生什么?对象将超出屏幕!在 uGUI 中,我们使用了锚点来解决这个问题,但这里没有。幸运的是,我们有右和底来帮助。
与左和顶属性一样,右和底定义了从父元素边界的距离(如果没有父元素,则直接从整个屏幕开始)。目前,这两个都设置为auto,这意味着位置将由左和右独立驱动,但通过改变这些值可以发生有趣的事情,所以让我们使用它们来使我们的得分和子弹标签粘附到屏幕的右上角,具体操作如下:
-
将光标放在视口中 UI 的底部,直到出现一个白色条。
-
拖动该条来调整屏幕大小并查看我们的元素是如何适应(或没有适应)不同大小的。
-
在侧边栏也做同样的操作,以查看它如何适应不同的屏幕宽度:

图 16.27:UI 没有适应不同的屏幕大小
-
在视口中选择得分标签并查看检查器。
-
在位置部分将顶和右的值设置为
30。 -
通过点击每个属性右侧的px按钮并选择auto来将左和底的值设置为
auto:

图 16.28:将位置属性的单位类型更改为自适应模式
- 注意标签两侧的金色方块变成了实心,而左侧和底部是空心的。这意味着左侧和底部处于auto模式。如果需要,您也可以通过点击这些方块来切换auto模式:

图 16.29:切换元素位置属性的自适应模式
-
再次尝试改变 UI 容器的尺寸,就像我们在步骤1和2中所做的那样,以查看我们的得分标签是如何始终对齐到右上角的。
-
对于子弹标签,重复步骤4到6,这次将顶属性设置为
140。
我们通过这些步骤所做的实际上是使对象的位置以像素为单位相对于 UI 的Top和Right边或屏幕的右上角来表示。我们需要将其他边设置为auto模式,这样它们就不会参与位置计算。
现在,我们也可以以其他方式使用Position属性。如您现在可能想象的那样,如果我们愿意,我们可以开始组合Left和Right以及Top和Bottom。在这种情况下,Left和Top将优先定义位置,但然后,Right和Bottom做什么呢?它们定义元素的大小。
例如,如果我们有一个元素,其Left和Right属性分别设置为100px,并且我们在一个宽度为1920像素的屏幕上查看我们的 UI,那么我们元素的最终宽度将是1720(1920减去Left的100减去Right的100)。这样,Position属性表示我们元素边框与屏幕边框(或父元素)的距离。
让我们通过以下方式来观察其效果:使底部生命条适应屏幕宽度,同时通过以下方式保持其相对于屏幕底部的位置:
-
在Hierarchy中选择底部生命条父元素。不要在Viewport中选择它,因为您只会选择其填充或边框。
-
将Left、Right和Bottom设置为
50px。 -
将Top设置为自动(点击右侧的px按钮并选择auto)。
-
在Size部分,也将Width设置为auto。
-
将Height设置为
35px:

图 16.30:使玩家的基础生命条适应屏幕宽度
- 改变 UI 的大小,看看它是如何适应的。
通过这些步骤,我们将屏幕边缘的条形距离定义为50像素,以便适应任何屏幕宽度,同时保持边框和高度固定。我们基本上实现了与 uGUI 中分割锚点相同的行为!请注意,我们需要将Size的Width属性设置为auto,以便让Left和Right属性驱动位置;如果不这样做,Width属性将具有优先级,并且Right将没有任何效果。我邀请您尝试其他px/auto的组合。
我们在这里可以做的最后一个技巧是,在健康条边框的Left、Top、Right和Bottom****Position属性中使用负值,使边框略大于容器并覆盖填充边框。在这种情况下,将Left、Top、Right和Bottom设置为-15px,并记住将Size Width和Height属性都设置为auto。您可能想要稍微减小条容器的高度(不是边框),因为现在它将因为这种变化而看起来更厚:

图 16.31:使用负 Position 属性覆盖填充
除了px(像素)或自动模式之外,还有一种百分比(%)模式,它允许我们以相对于屏幕(或如果有,父元素)大小的百分比来表示值。例如,如果我们将顶部和底部设置为25%,这意味着我们的元素将在垂直居中,其大小为屏幕高度的 50%(请记住在此处将高度模式设置为自动)。如果我们将顶部设置为25%,底部设置为自动,高度设置为50%,我们也可以达到相同的结果;如您所见,我们可以巧妙地组合这些值。
在我们的案例中,我们将使用百分比值在我们的生命条填充中,这样我们就可以用百分比来表示其大小。我们需要这一点,因为稍后我们可以在代码中将条宽指定为玩家生命值的百分比(例如,一个有25生命值和最大100点的玩家有25%的生命)。
现在,虽然我们通过使用左、顶、右和底属性解决了屏幕大小的定位适应问题,但我们还没有解决元素的动态大小问题。这次,我们指的是具有不同DPI(每英寸点数)的屏幕,因此让我们讨论如何通过面板设置资产来实现这一点。
动态缩放
我们使用1920x1080作为 UI 基本分辨率来定位和调整元素的大小,以便在该分辨率下看起来很漂亮。我们还更改了 UI 大小,以查看元素如何适应不同的屏幕尺寸;虽然这效果很好,但您会注意到在这样做时元素看起来更大或更小。
虽然有一个基本参考分辨率对于设计我们的 UI 是好的,但我们应考虑不同分辨率下元素的大小,尤其是在高 DPI 的屏幕上。有时,您可能会有更高分辨率的屏幕,但物理尺寸相同(以厘米为单位)。这意味着高分辨率中的像素更小,因此它们具有更高的 DPI,所以如果未正确缩放,元素看起来可能会更小。
在过去,我们使用画布缩放器组件的画布来使 UI 根据屏幕分辨率调整其元素的大小。我们在这里的设置与 UI 文档组件中引用的面板设置资产中的设置完全相同,因此让我们通过以下方式来配置它:
- 在项目面板中查找面板设置资产并选择它。另一种选择是在主编辑器层次结构中选择
UI 文档GameObject,并点击面板设置属性中引用的资产:

图 16.32:UI 文档组件中引用的面板设置
-
将缩放模式设置为与屏幕大小缩放。
-
将屏幕匹配模式设置为匹配宽度或高度。
-
将参考分辨率的X值设置为
1920,Y值设置为1080。 -
将匹配滑块完全向右移动,直到标记为高度的末端:

图 16.33:设置我们的 UI 缩放
- 观察如何改变 Unity 编辑器中游戏面板的高度,将使 UI 相应地调整元素大小(改变整个 Unity 编辑器窗口的高度)。
我们通过这些更改首先将参考分辨率设置为我们的 UI 设计的任何分辨率,在我们的例子中,是1920x1080。然后,我们将屏幕匹配模式设置为允许我们根据一边、宽度、高度或两者的组合来缩放我们的元素,如果我们更喜欢的话。在我们的例子中,我们选择了高度,主要是因为我们的游戏针对 PC,那里的屏幕更宽而不是更高。这意味着在不同的屏幕宽度上,元素看起来大小相同,但在不同的高度上,元素会更大或更小。
使用这些设置,我们可以做一些数学计算来理解这些值。如果我们的屏幕分辨率与参考分辨率(1920x1080)相同,那么元素的大小将与我们在像素大小中指定的元素大小相同,所以对于我们的玩家角色来说,它将是150x150像素。记住,物理大小以厘米为单位取决于屏幕的 DPI。
现在,想象一下我们有一个 4k 屏幕,这意味着分辨率为3840x2160。因为我们指定了我们的 UI 通过高度匹配,所以我们可以确定我们的元素大小将加倍,因为我们的屏幕高度是参考分辨率的两倍(2160除以1080)。我们的玩家角色将是300x300,使得元素在 4k 屏幕上具有相同的物理大小,双倍大小但双倍像素密度实现了这一点。最后,考虑一个超宽标准分辨率2560×1080(是的,非常宽的屏幕),在这种情况下,元素的大小将与宽度唯一的变化相同;唯一的区别是,由于屏幕大小,元素将会有更多的水平间隔。我知道这些计算可能会让人困惑,但请继续实验面板设置和游戏视图大小,以更好地理解它们。
太好了,现在我们真的有了相同的 HUD。我们可以开始将到目前为止看到的概念应用到选项菜单中,但让我们抓住机会以不同的方式来做,使用相对位置,这是一种创建元素流动的方法,其中元素的位置相互依赖。
使用相对位置
在我们游戏的 HUD(抬头显示)中,每个元素都需要其自己的位置和大小,并且不同元素的位位置可以调整大小和重新定位,而不会影响其他元素。我们可以观察到玩家生命条和角色的例子,但在这个情况下变化是微不足道的。还有其他情况,这种情况并不那么简单,比如一个列表元素(例如,在多人游戏中要加入的匹配列表)需要垂直或水平调整,这就是相对位置帮我们解决问题的地方。
相对位置允许我们使元素的位置相互关联;从某种意义上说,一个元素的位置将取决于前一个元素的位置,前一个元素的位置又取决于其前一个元素,依此类推,形成一个链或流。这就像在 uGUI 上的垂直和水平布局一样工作。在我们的情况下,我们将使用这些来使我们的选项菜单中的暂停标签以及选项和退出按钮垂直对齐并沿其父元素居中。
让我们开始创建菜单,按照以下步骤操作:
-
创建一个新的 UI 文档(在项目视图 | UI Tookit | UI Document后点击+按钮)并命名为
OptionsMenu。我们可以继续在之前的 UI 文档上工作,但让我们将这些 UI 部分分开,以便于激活和停用,以及一般资产组织。 -
双击资产将其设置为当前由UI Builder编辑的 UI。
-
选择根对象(OptionsMenu.uxml在Hierarchy中)并将宽度和高度的检查器属性设置为
1920x1080像素。 -
创建一个新的具有 UI 文档组件(GameObject | UI Toolkit | UI Document)的 GameObject,并将此对象的资产拖动以渲染它(就像我们在本章早期创建的 HUD 所做的那样)。
-
双击 UI 文档资产以打开UI Builder窗口进行编辑,并在该窗口中,将一个新的视觉元素拖动到Hierarchy或视口并命名为
Container(在UI Builder的检查器中的名称属性)。 -
将左、右、上和右的位置属性设置为
0px。 -
将位置设置为绝对。
-
在大小部分将宽度和高度设置为自动。这将使容器适应整个屏幕。
-
将一个新的视觉元素拖动到容器下作为子元素并命名为
Background。 -
这次将位置保留为相对。
-
将大小的宽度和高度设置为
500px。 -
将背景图像的背景对象设置为使用上一章中使用的相同背景精灵。
-
选择容器父对象(不是背景)。
-
在检查器中,将对齐项属性设置为居中,这是第三个按钮。如果将鼠标悬停在图标上,它们将在工具提示中显示其名称。
-
将Justify Content设置为居中(第二个按钮):

图 16.34:准备 UI 背景以容纳内部元素
- 使用侧面的白色条调整 UI 的大小,以查看背景始终居中。
即使只有一个元素,我们也可以开始看到相对位置是如何工作的。首先,我们创建了一个始终适应屏幕大小的空对象,使我们能够使子元素依赖于整个屏幕大小。然后,我们创建了一个具有固定大小的图像元素,但具有相对位置,这意味着其位置将由父容器计算。最后,我们告诉容器使其子对象与它的水平和垂直中心对齐,因此背景立即居中,无论屏幕大小如何。当使用绝对位置时,对齐属性不起作用,因此这是相对定位的第一个好处之一。
但相对定位在多个元素中变得更加强大,因此让我们将标签和按钮添加到我们的背景元素中,通过以下方式进一步探索这一概念:
- 从UI Builder左下角的库面板中,拖动一个标签和两个按钮元素到层次结构中的背景内。注意,有时即使你将新元素拖放到目标对象内,它也不会成为其子元素。这次请只拖动在层次结构中创建的元素:

图 16.35:在菜单背景内添加元素
- 观察默认情况下,由于相对位置的默认设置,元素如何垂直对齐,一个叠在另一个上面:

图 16.36:自动相对垂直定位
-
选择背景元素,并将内容对齐设置为
space-around(第五个按钮)。这将使元素沿背景分布。 -
将对齐元素设置为居中(第三个选项)以水平居中元素:

图 16.37:自动相对垂直定位
对于内容对齐有一个类似的模式,称为“space-between”(在内容对齐中的第四个按钮),它也会沿着垂直轴分布元素,但不会在第一个元素的顶部或最后一个元素的底部留下空间。此外,对齐元素有一个名为拉伸(第五个选项)的选项,与居中类似,它将在水平方向上居中元素,但也会拉伸它们而不是尊重每个元素的宽度。我建议尝试不同的对齐模式,以发现所有机会。
-
将标签的文本的字体和大小属性设置为适合的任何值。在我的情况下,我使用了导入的字体和
60px的大小。记得也要将文本设置为暂停。 -
将按钮的背景图片设置为与上一章中使用的按钮相同的图片。
-
将Background部分的Color属性设置为没有 alpha 的颜色。你可以通过点击颜色矩形并减少颜色选择器中的A通道到
0来实现这一点。这种颜色的想法是作为我们图像的背景,但我们不需要它,所以我们使其完全透明。 -
将按钮的Text的Font、Size和Color设置为对你来说合适的内容。在我的情况下,我使用
50和灰色。 -
在Margin and Padding部分,将Padding设置为在文本和按钮边框之间留出一些空间。在我的情况下,
30px就做到了这一点:

图 16.38:为按钮内容添加内部填充(在这个例子中是文本)
- 还要设置Background的Top和Bottom****Padding,以便在窗口边框和其元素之间留出一些空间。在我的情况下,每个都是
40px。
如你所见,我们更改了不同的设置来动态设置元素的大小,如字体大小和填充,以及相对系统以及对齐设置自动确定元素的位置。我们可以通过在层次结构中拖动元素来重新排列元素的顺序,它们将自动适应。我们也可以使用Size属性设置元素的大小,并且我们可以使用Position属性应用一些偏移量,如果需要的话,但我鼓励你自己看看这些属性在相对模式下的行为。
最后一个我想让你探索的设置是Flex部分的Direction属性,正如你可以想象的那样,这将决定元素将遵循的朝向,垂直方向从上到下或从下到上,以及水平方向从左到右或从右到左。例如,你可以将Direction设置为使用row模式(第三个按钮)从左到右分配元素,如果你希望的话,可以使背景更宽以拥有一个水平选项菜单。

图 16.39:将元素更改为垂直方向
作为旁注,你可能注意到背景和按钮的图像看起来比上一章中完成的选项菜单要大。这是因为我们在Texture资产上更改的Pixels per Unit设置,用于控制纹理的缩放,在 UI Toolkit 中不会生效;你需要手动在任何图像编辑器中更改纹理文件大小以给出适当的大小。这里的最佳实践是始终创建大小适合我们最大支持的分辨率的图像。通常,在 PC 上是1920x1080,但请注意,4k 分辨率每天都在变得越来越流行。
摘要
在本章中,我们介绍了 UI 工具包的关键概念以及如何创建 UI 文档和样式表。关于 UI 文档,我们学习了如何创建不同的元素,如图片、文本和按钮,以及如何使用不同的方法(如绝对和相对定位,以及像素或百分比单位)来定位和调整它们的大小。此外,我们还看到了如何通过不同的位置属性组合来使 UI 适应不同的尺寸。最后,我们学习了如何使用 USS 样式表在不同元素之间共享样式,以便轻松管理整个 UI 皮肤。
实质上,我们再次学习了如何使用不同的系统来制作 UI 界面。再次提醒,这个系统仍然处于实验阶段,不建议用于实际的生产项目。我们使用所有这些概念来重新创建在第十五章,用户界面设计中创建的相同 UI 界面。
在下一章中,我们将看到如何为我们的游戏添加动画,使我们的角色移动。我们还将了解如何创建剪辑场景和动态摄像机。
第十七章:使用 Animator、Cinemachine 和 Timeline 创建动画
有时,我们需要以预定的方式移动对象,例如在场景中,或者特定的角色动画,如跳跃、奔跑等。在本章中,我们将介绍几个 Unity 动画系统,以创建我们可以通过脚本获得的所有可能的物体运动。
在本章中,我们将检查以下动画概念:
-
使用 Animator 进行皮肤动画
-
脚本动画
-
使用 Cinemachine 创建动态相机
-
使用 Timeline 创建场景
到本章结束时,你将能够创建场景来讲述你游戏的历史或突出你关卡中的特定区域,以及创建能够根据情况提供准确外观的动态相机。
使用 Animator 进行皮肤动画
到目前为止,我们使用的是所谓的静态网格,它们是固体三维模型,不应该以任何方式弯曲或动画化(除了像汽车门那样单独移动)。
我们还有一种另一种网格,称为皮肤网格,它具有根据骨骼弯曲的能力,因此可以模拟人体肌肉运动。我们将探讨如何将动画化的类人角色集成到我们的项目中,以创建敌人和玩家的动作。
在本节中,我们将检查以下骨骼网格概念:
-
理解皮肤
-
导入皮肤网格
-
使用 Animator 控制器进行集成
-
使用 Avatar 面具
我们将探讨皮肤的概念以及它如何让你对角色进行动画化。然后,我们将把动画网格带入我们的项目,最终对它们应用动画。让我们先讨论如何将骨骼动画带入我们的项目。
理解皮肤
为了获得一个动画网格,我们需要四个部分,首先是将要动画化的网格,其创建方式与其他网格相同。然后,我们需要骨骼,它是一组骨头,将匹配所需的网格拓扑结构,例如手臂、手指、脚等。在图 17.1中,你可以看到一个与我们的目标网格对齐的骨头集合示例:

图 17.1:一个与默认姿势匹配的骨骼忍者网格
一旦艺术家创建了模型及其骨骼,下一步就是进行皮肤绑定,这是将模型每个顶点关联到一个或多个骨骼的行为。这样,当你移动一个骨骼时,相关的顶点会随着它移动。在图 17.2中,你可以看到网格的三角形根据影响它的骨骼颜色进行绘制,作为可视化骨骼影响的一种方式。你会注意到颜色之间的混合,这意味着那些顶点受到不同骨骼的影响,以便使接近关节的顶点能够弯曲得很好。此外,图 17.2还展示了用于二维游戏的二维网格的示例,但概念是相同的:

图 17.2:网格皮肤权重以颜色形式直观表示
最后,你需要的是实际的动画,它将简单地由网格骨骼的不同姿势的混合组成。艺术家将在动画中创建关键帧,确定模型在不同时刻需要具有的姿势,然后动画系统将简单地在这之间进行插值。基本上,艺术家将动画化骨骼,而皮肤系统将应用此动画到整个网格上。
为了获得这四个部分,我们需要获取包含它们的适当资产。在这种情况下,通常的格式是Filmbox(FBX),我们之前用它来导入 3D 模型。此格式可以包含我们需要的每一部分——模型、带有皮肤骨骼和动画——但通常这些部分将分成几个文件以供重新利用。
想象一个城市模拟器游戏,其中我们拥有几个具有不同特征的市民网格,并且所有这些网格都必须是动态的。如果我们为每个市民创建一个包含网格、皮肤和动画的单个 FBX 文件,那么每个模型都将拥有自己的动画,或者至少是相同动画的克隆,重复播放。当我们需要更改该动画时,我们需要更新所有网格市民,这是一个耗时的工作。相反,我们可以为每个市民创建一个包含网格和基于该网格适当皮肤骨骼的 FBX 文件,以及每个动画一个单独的 FBX 文件,包含所有市民都有的相同骨骼和适当的动画,但不包含网格。这将使我们能够混合和匹配市民 FBX 文件和动画 FBX 文件。你可能想知道为什么模型 FBX 和动画 FBX 都必须包含网格。这是因为它们需要匹配,以便使两个文件兼容。在图 17.3中,你可以看到文件应该如何看起来:

图 17.3:我们将用于项目的动画和模型 FBX 文件包
此外,值得一提的是一个称为重定位的概念。正如我们之前所说,为了混合模型和动画文件,我们需要它们具有相同的骨骼结构,这意味着相同的骨骼数量、层次结构和名称。
有时候,这是不可能的,尤其是在我们将我们艺术家创建的定制模型与使用动作捕捉技术从演员那里记录的外部动画文件混合时,或者只是通过购买一个动作捕捉(动作捕捉)库,一组使用特定动作捕捉硬件在真实人类上捕捉的动画。在这种情况下,你很可能会在动作捕捉库和你的角色模型之间遇到不同的骨骼结构,这就是重定向发挥作用的地方。这项技术允许 Unity 在两个不同的人形骨骼结构之间创建一个通用的映射,使它们兼容。在下一节导入骨骼动画中,我们将看到如何启用此功能。
现在我们已经了解了着色网格背后的基础知识,让我们看看我们如何获取带有骨骼和动画的模型资产。
导入骨骼动画
你可以通过在资产商店的3D | 角色 | 人类部分搜索来下载一个角色模型。你也可以使用外部网站,例如名为 Mixamo 的网站来下载它们。请注意,有时你可能需要下载几个包,因为有时包只包含着色模型,而其他包只包含动画。幸运的是,我们下载的这个已经包含了着色网格和动画。
在我的包内容中,我可以在Animations文件夹中找到动画的 FBX 文件,以及我在Mesh文件夹中的模型Polyart_Mesh的 FBX 文件。记住,有时它们不会像这样分开,如果存在任何动画,动画可能位于与模型相同的 FBX 文件中。现在我们已经有了所需的文件,让我们讨论如何正确配置它们。
让我们从选择模型文件并检查绑定标签开始。在这个标签中,你会找到一个名为动画类型的设置,如图图 17.4所示:

图 17.4:绑定属性
此属性包含以下选项:
-
无:用于非动画模型的模式;你的游戏中的每个静态网格都将使用此模式。
-
旧版:用于旧 Unity 项目和模型的模式;不要在新项目中使用。
-
通用:一种可用于所有类型模型的全新动画系统,但通常用于非人类模型,如马、章鱼等。如果你使用此模式,模型和动画 FBX 文件必须具有完全相同的骨骼名称和结构,从而减少了从外部来源组合动画的可能性。
-
人类:为人类模型设计的新动画系统。它启用了诸如重定位和逆运动学(IK)等功能。这允许你将具有不同骨骼的模型与动画结合,因为 Unity 将创建这些结构与通用结构之间的映射,称为化身。请注意,有时自动映射可能会失败,你需要手动进行更正;因此,如果你的通用模型包含你需要的一切,我建议如果你是 FBX 的默认配置,就坚持使用通用。
在我的情况中,我的包中的 FBX 文件模式设置为人类,这是好的,但请记住,只有当绝对必要时才切换到其他模式(例如,如果您需要组合不同的模型和动画)。现在我们已经讨论了绑定设置,让我们谈谈动画设置。
为了做到这一点,选择任何动画 FBX 文件,并在检查器窗口中查找动画选项卡。你将找到几个设置,例如,如果文件包含动画(不是模型文件),则必须勾选导入动画复选框,以及片段列表,其中包含文件中的所有动画。在下面的屏幕截图中,你可以看到我们的动画文件之一的片段列表:

图 17.5:动画设置中的片段列表
带有动画的 FBX 文件通常包含一个单独的大动画轨道,该轨道可以包含一个或多个动画。无论如何,默认情况下,Unity 将根据该轨道创建一个动画,但如果该轨道包含多个动画,你需要手动拆分它们。在我们的案例中,我们的 FBX 包含一个单独的动画,但为了了解如何在其他情况下拆分它,请按照以下步骤操作:
-
从片段列表中选择任何你想要重新创建的动画;以我的情况为例,我将选择
Run_guard_AR。 -
查看动画时间线下的开始和结束值,并记住它们;我们将使用它们来重新创建这个片段:

图 17.6:片段设置
-
使用+按钮创建一个新的片段并选择它。
-
使用输入框当前显示的类似
Take 001的内容将其重命名为与原始名称相似的内容。以我的情况为例,我将命名为Run。 -
使用步骤 2 中记住的值设置结束和开始属性。以我的情况为例,我将结束设置为
20,开始设置为0。这些信息通常来自制作动画的艺术家,但你可以尝试最适合的数字,或者简单地拖动时间线上的蓝色标记到这些属性上。 -
如果动画需要循环播放,请勾选循环时间复选框以确保这一点。这将使动画不断重复,这在大多数动画如行走或跑步中是必需的。如果不勾选,动画将只播放一次,不会重复:

图 17.7:循环动画
- 通过点击检查器窗口底部为你的动画命名的栏(在我的例子中是运行)并点击播放按钮来预览剪辑。在某些情况下,你可以看到默认的 Unity 模型,但你可以通过将模型文件拖到预览窗口中看到自己的模型,因为检查我们的模型是否正确配置非常重要。如果动画没有播放,你需要检查动画类型设置是否与动画文件匹配:

图 17.8:动画预览
- 通过点击其左侧的箭头打开动画资产(FBX),并检查子资产。你会看到有一个与你的动画同名资产:

图 17.9:生成的动画剪辑
- 记住,除了初始化帧、结束帧和循环时间之外,还有很多其他设置。我下载的角色需要其他设置,如根变换旋转、根变换位置和遮罩才能正常工作,不同角色包之间的差异可能很大。如果你正在重新创建现有的动画,考虑复制所有设置,或者只使用默认设置。这些提到的设置超出了本书的范围,但你可以在 Unity 文档中查阅它们,网址为
docs.unity3d.com/Manual/class-AnimationClip.html。
现在我们已经介绍了基本配置,让我们学习如何集成动画。
使用动画控制器进行集成
当向我们的角色添加动画时,我们需要考虑动画的流程,这意味着思考哪些动画必须播放,每个动画何时必须处于活动状态,以及动画之间的转换应该如何发生。在之前的 Unity 版本中,你需要手动编写代码,生成复杂的 C#脚本以处理复杂场景;但现在,我们有动画控制器。
动画控制器是一种基于状态机的资产,我们可以使用名为Animator的可视化编辑器来绘制动画之间的转换逻辑。其理念是每个动画都是一个状态,我们的模型将包含多个状态。一次只能有一个状态处于活动状态,因此我们需要创建转换来改变它们,这些转换将具有必须满足的条件才能触发转换过程。条件是对要动画化的角色数据的比较,例如其速度、是否在射击或蹲下等。
因此,基本上,动画控制器或状态机是一组具有转换规则的动画,它将决定哪个动画应该处于活动状态。让我们通过以下步骤开始创建一个简单的动画控制器:
-
在项目视图下点击+按钮,点击动画控制器,并将其命名为
Player。请记住,将您的资产定位在文件夹中以进行适当的组织;我将我的命名为Animations。 -
双击资产以打开动画器窗口。不要将此窗口与动画窗口混淆;动画窗口用于创建新的动画,但到目前为止,我们将坚持使用下载的动画。
-
在你角色的动画包的动画文件夹中搜索你角色的空闲动画片段,并将其拖入动画器窗口。在我的例子中,它被命名为Idle_guard_ar。请记住,拖动子资产,而不是整个文件。这将在控制器中创建一个代表动画的框,该框将连接到控制器的入口点,表示该动画将是默认的,因为它是我们首先拖动的。如果你没有空闲动画,我鼓励你从资产商店下载一个,也许可以在其他角色的包中搜索。我们需要至少一个空闲和一个行走/跑步动画片段:

图 17.10:将动画片段从 FBX 资产拖动到动画控制器
-
以相同的方式拖动跑步动画,在我的例子中是Run_guard_AR。
-
右键点击动画器窗口中的空闲动画框,选择创建过渡,然后左键点击跑动画。这将创建空闲和跑之间的过渡。
-
以相同的方式从跑到空闲创建另一个过渡:

图 17.11:两个动画之间的过渡
过渡必须具有条件,以防止动画不断交换,但为了创建条件,我们需要用于比较的数据。我们将向我们的控制器添加属性,这些属性将代表过渡使用的数据。稍后,在本章的脚本动画部分,我们将设置这些数据以匹配我们对象当前的状态。但到目前为止,让我们创建数据并测试控制器对不同值的反应。为了根据属性创建条件,请执行以下操作:
-
在动画器窗口的左上角点击参数选项卡。如果您看不到它,请点击看起来像被一条线交叉的眼睛的按钮以显示选项卡。图标将变为未交叉的眼睛。
-
点击+按钮并选择浮点数来创建一个代表我们角色速度的数字,命名为
Velocity。如果您错过了重命名部分,只需左键点击变量并重命名它:

图 17.12:带有浮点速度属性的参数选项卡
-
点击空闲到跑过渡(中间有箭头的白色线条)并查看检查器窗口中的条件属性。
-
点击列表底部的+按钮,这将创建一个将控制转换的条件。默认设置将取我们动画器的第一个参数(在这种情况下,它是速度),并将默认比较器设置为大于,值为
0。这告诉我们,如果空闲是当前动画且玩家的速度大于0,则转换将从空闲到跑动执行。我建议您设置一个稍高一点的值,例如0.01,以防止任何浮点舍入错误(一个常见的 CPU 问题)。此外,请记住,速度的实际值需要通过脚本手动设置,我们将在本章的脚本动画部分进行操作:

图 17.13:检查速度是否大于 0.01 的条件
- 对跑动到空闲的转换也做同样的操作,但这次将大于改为小于,并将值设置为
0.01:

图 17.14:检查值是否小于 0.01 的条件
现在我们已经设置了第一个动画控制器,是时候将其应用到对象上了。为了做到这一点,我们需要一系列组件。首先,当我们有一个动画角色时,而不是常规的网格渲染器,我们使用皮肤网格渲染器。如果您选择您的玩家或敌人角色并查看它们的子对象,GameObject,您将看到皮肤网格渲染器在它们中的一个或多个中:

图 17.15:皮肤网格渲染器组件
此组件将负责将骨骼的运动应用到网格上。如果您搜索模型的子对象,您将找到一些骨骼;您可以尝试旋转、移动和缩放它们以查看效果,如下面的截图所示。请注意,如果从资产商店下载了另一个包,您的骨骼层次结构可能与我不同:

图 17.16:旋转颈骨
我们需要的另一个组件是动画器,它将自动添加到根 GameObject 的皮肤网格中。如果动画 FBX 文件配置正确,该组件将负责应用我们在动画控制器中创建的状态机,正如我们之前提到的。为了应用动画控制器,请执行以下操作:
-
在层次结构中选择玩家,并在根 GameObject 中定位动画器组件。
-
点击控制器属性右侧的圆圈,并选择我们之前创建的玩家控制器。您也可以直接从项目窗口拖动它。
-
确保将 Avatar 属性设置为角色 FBX 模型内部的头像(在我们的示例项目中,
Polyart_Mesh是 FBX 模型);这将告诉动画师我们将使用该骨架。您可以通过以下截图所示的人形图标识别头像资产。通常,当您将 FBX 模型拖动到场景中时,此属性会自动正确设置:

图 17.17:使用 Player 控制器和机器人角色头像的动画师
- 在不停止游戏的情况下,通过双击它并选择 Hierarchy 窗格中的角色来再次打开 Animator 控制器资产。通过这样做,您应该看到该角色正在播放的当前动画状态,使用条形图表示动画的当前部分:

图 17.18:在 Play 模式下选择对象时,Animator 控制器显示当前动画及其进度
- 使用 Animator 窗口,将 Velocity 的值更改为
1.0并观察过渡如何执行。如果您想测试,可以禁用 WaveSpawners,因为它们可能会在我们安全执行之前杀死玩家:

图 17.19:设置控制器的速度以触发过渡
-
根据如何设置 Run 动画,您的角色可能会开始移动而不是在原地执行动画。这是由根运动引起的,这是一个基于动画运动移动角色的功能。有时这很有用,但由于我们将完全使用脚本移动我们的角色,我们希望关闭此功能。您可以通过在 Character 对象的 Animator 组件中取消选中 Apply Root Motion 复选框来实现这一点,如图 17.17 所示。
-
您还会注意到在更改 Velocity 值和动画过渡开始之间有一个延迟。这是因为默认情况下,Unity 将等待原始动画结束后再执行过渡,但在这个场景中,我们不想这样。我们需要过渡立即开始。为了做到这一点,选择控制器的每个过渡,并在检查器窗口中取消选中 Has Exit Time 复选框。当此属性被选中时,过渡执行的隐藏条件是等待动画结束。但取消选中后,过渡可以在动画的任何时刻执行,这正是我们想要的,因为我们不希望在玩家空闲和跑步之间有任何延迟:

图 17.20:取消选中“具有退出时间”复选框以立即执行过渡
你可以将其他动画拖入控制器,并创建复杂的动画逻辑,例如添加跳跃、跌倒或蹲下动画。我邀请你尝试其他参数类型,例如布尔值,它使用复选框而不是数字。此外,随着你游戏的进一步开发,你的控制器中的动画数量将会增加。为了管理这一点,有一些其他值得研究的功能,例如混合树和子状态机,但这超出了本书的范围。
在本节中,我们学习了如何通过 Animator Controllers 将动画剪辑集成到我们的角色中。我们添加了所有需要的动画,并创建了它们之间的必要过渡,以应对游戏情况,如角色速度变化。
现在我们已经集成了空闲和跑步动画,让我们集成射击动画,这需要我们使用Avatar Masks。
使用 Avatar Masks
起初,这个案例看起来就像拖动一个射击动画并创建使用Shooting布尔参数作为条件的过渡那么简单。然而,考虑到我们可以在行走和跑步时射击,这导致有两个射击动画,行走射击和空闲射击。如果你遵循这个逻辑,你可以想到在跌倒、跳跃等情况下射击,这会导致更多的动画组合。想象一下为不同的武器拥有不同的射击动画!幸运的是,我们有一个更好的解决方案:使用 Avatar Masks 结合多个动画的方法。
我们在 Animator Controller 中创建的动画状态机被称为层,一个 Animator Controller 可以有多个层。这意味着我们可以在 Animator Controller 中拥有多个状态机。使用这种方法有几个原因,但常见的一个是将层与 Avatar Masks 结合,这是一个允许我们使特定的 Animator Controller 层或状态机影响某些骨骼的资产,因此我们可以为身体的不同部分设置不同的状态机。
我们可以用这个方法来解决之前讨论过的射击场景,将我们的玩家动画逻辑分成两部分,上半身和下半身。想法是下半身将在空闲和跑步动画之间切换,而上半身可以在空闲、跑步和射击之间切换。这允许我们拥有下半身跑步而上半身射击的场景,或者下半身空闲而上半身也空闲,或者任何我们可以想象到的组合。
让我们从创建第二个层开始,按照以下步骤操作:
-
如果你还没有射击动画,可以从互联网或 Asset Store 下载一个。在我们的例子中,我们已经有了一些射击动画,我们将选择一个叫做
Idle_Shoot_ar的动画。 -
在动画控制器中,在基本层上单击一次,并将其重命名为下半身。如果您看不到层列表,请点击动画窗口左上角的层按钮:

图 17.21:重命名基本层
-
使用+按钮在控制器中添加第二个层,并将其重命名为
UpperBody。 -
选择层,并添加空闲、奔跑和射击动画到它,通过转换连接状态。记得在每个转换中取消勾选有退出时间:

图 17.22:上半身状态机
-
使用之前相同的转换逻辑在空闲和奔跑之间添加,使用速度作为条件参数,如之前所述。
-
对于射击转换,创建一个名为射击的布尔参数:

图 17.23:射击布尔值
-
当射击布尔值为真时,使两个射击转换(空闲到射击和奔跑到射击)执行。
-
当射击布尔值为假且速度小于
0.01时,从射击状态过渡到空闲状态,当射击为真且速度大于0.01时,从射击状态过渡到奔跑状态:

图 17.24:顶部的射击到空闲转换,中间的射击到奔跑转换,以及底部的空闲到射击和奔跑到射击转换
现在我们已经创建了层,让我们将这些 Avatar Masks 应用到它们上:
-
使用项目视图中的+按钮创建一个 Avatar Mask,并将其命名为
UpperBodyMask。 -
在检查器中选择
UpperBodyMask资产,并点击左侧显示人类到扩展此部分的箭头。 -
点击显示在检查器中的身体下部的部分,直到它们变成红色:

图 17.25:UpperBodyMask 资产配置
-
在动画控制器中,选择上半身层,并点击其右侧的轮子以显示一些选项。
-
点击遮罩属性的右侧圆圈,并在出现的窗口中选择UpperBodyMask资产。
-
再次点击上半身层的轮子,并将其权重设置为
1。由于两个层影响身体的不同的部分,它们都有相同的优先级。在两个层影响相同骨骼的情况下,权重用于计算哪个层有更大的影响:

图 17.26:设置层的权重和遮罩
-
再次点击轮子,并观察混合参数是否设置为覆盖,这意味着此层影响的骨骼(由 Avatar Mask 驱动)将覆盖基本层(在这种情况下为下半身)的任何动画。这就是这个层如何接管身体的上半部分。
-
在播放模式下更改参数的值,再次进行测试。例如,尝试勾选射击,然后将速度设置为
1,然后设置为0,最后取消勾选射击,看看过渡是如何执行的。 -
你可能会注意到,当射击时,我们的角色可能不会指向正确的方向。这是因为与空闲和跑相比,角色的方向被修改了,但基础层仍然拥有这个权限。我们可以通过点击人类部分中人物底部的圆圈来使UpperBodyMask控制方向,直到它变成绿色:

图 17.27:赋予面具对玩家方向的权限
这里的问题是,你现在会看到角色在跑步和射击时将脚向侧面移动。除了修改原始动画之外,这里没有简单的解决方案。在这种情况下,这个角色有 Idle、Idle Shooting、Run 和 Run Shooting 动画,所以很明显,它是在没有考虑到 Avatar Masks 的情况下创建的,而是只考虑了所有可能的动画组合。一个替代方案是找到另一个与 Avatar Masks 配合得更好的包。为了学习目的,我们将坚持使用这个,但请注意,Avatar Masks 不是必需的;你可能只需要使用单个动画控制器状态机中的所有可能的动画排列,并包含所有需要的过渡,就可以很好地进行。
当射击动画正在播放时,你可能注意到的一个问题是,枪口效果会停留在武器的原始位置。由于武器网格受到皮肤动画的影响,但不受其变换位置的影响,因此枪口无法跟随它。为了解决这个问题,你可以将枪口效果重新父级化到武器的一个骨骼上——在这个例子中,是名为 Trigger_Right 的 GameObject,它是 Hips GameObject 的一个子对象。并非所有动画都会有武器的骨骼,所以这是你可能遇到的可能场景之一:

图 17.28:将枪口效果重新父级化到武器的一个骨骼
- 记得将我们对玩家所做的相同更改应用到敌人身上,这意味着将玩家动画控制器添加并设置到其动画器组件中,并更改
枪口效果父级。
现在我们已经有一个完全功能的动画控制器,让我们通过脚本让它反映玩家的移动。
脚本化动画
我们的玩家动画器准备好了,现在是时候进行一些脚本编写,让这些参数受到玩家实际行为的影响,并与玩家的行为相匹配。在本节中,我们将执行以下操作以实现这一点:
-
脚本化射击动画
-
脚本化移动动画
让我们开始制作我们的角色在必要时执行射击动画。
脚本化玩家射击动画
到目前为止,我们已经创建了一个在每次按下键时射击的行为,但动画是为持续射击准备的。我们可以使我们的PlayerShooting脚本在保持Fire键按下的同时,每 X 秒发射一颗子弹,以匹配动画,而不是需要重复按键。
让我们看看如何做这件事:
-
在PlayerShooting脚本中,添加一个名为fireRate的公共 float 字段,该字段将测量子弹生成的秒数。请记住在玩家的Inspector中设置此值。
-
将OnFire方法更改为如图 17.29 所示的代码。我们的想法是在按下键时启动重复动作,并在释放键时停止它。我们正在使用InvokeRepeating来重复执行名为Shoot的函数,我们将在下一步创建它。执行速率将由我们在步骤 1中创建的fireRate字段控制:

图 17.29:持续射击所需的 OnFire 更改
- 将如图 17.30 所示的Shoot方法添加到我们的PlayerShooting脚本中。这基本上与我们在OnFire方法中之前拥有的代码相同,但已分离成一个函数,因此我们可以使用InvokeRepeating函数多次执行它:

图 17.30:持续射击所需的 OnFire 更改
如果你现在尝试这些更改,你会注意到一旦我们点击Fire按钮,子弹就不会停止射击。更糟糕的是,随着我们反复按下,发射的子弹会越来越多。通过一些调试或合理的猜测,你可能会发现CancelInvoke方法没有被执行。背后的原因是Fire输入映射默认没有配置来通知我们键的释放,只是在它们被按下时。幸运的是,解决方案相当简单:
-
双击SuperShooter输入资产,这是我们创建在第六章,实现移动和生成中,包含我们游戏支持的所有输入的那个。
-
在Actions列表(中间列)中选择Fire动作。
-
点击Interactions部分右侧的+按钮,然后点击Press。
-
将Press部分的Trigger Behavior设置为Press And Release:

图 17.31:持续射击所需的 OnFire 更改
- 通过这种方式,我们已经配置了输入,不仅告诉我们键何时被按下,还告诉我们何时被释放,使我们的CancelInvoke方法现在执行。
现在我们有了恒定的射击行为,我们可以做以下操作来使动画反映这一点:
- 在Awake中使用GetComponent添加 Animator 的引用,并在字段中缓存,如图 17.32 所示:

图 17.32:缓存 Animator 引用
- 在OnFire方法的开头添加
animator.SetBool("Shooting", value.isPressed);这一行。

图 17.33:设置射击动画参数以反映输入
- 这个变更背后的想法是确保射击动画参数反映了开火键的状态,这意味着只要按下开火按钮,射击动画就会播放,当我们释放它时就会停止。
您会注意到的一个问题是子弹仍然是从玩家的胸部发射出来的,因为我们的ShootPoint游戏对象,即定义射击位置的物体,并没有位于武器的前方。只需将ShootPoint重新设置为武器的骨骼(在我们的例子中是Trigger_Right)并将其定位在武器前方。记住要使前向向量(场景视图中的蓝色箭头)沿着武器方向:

图 17.34:使射击点跟随动画
对于视觉脚本版本,为了使子弹持续发射,你应该像图 17.35 中那样更改PlayerShooting的Input节点:

图 17.35:创建射击循环
如您所见,我们使用了一个名为计时器的新节点。计时器的想法与之前使用的等待几秒节点类似,因为它允许我们延迟执行一个动作。主要区别在于它允许我们在再次执行之前取消计时器,这意味着我们可以在按下开火键时启动计时器,并在释放它时停止。我们通过将具有OnPressed模式的InputSystemEventButton节点连接到计时器的Start引脚,将具有OnReleased模式的节点连接到Pause引脚来实现这一点。此外,我们创建了一个名为fireRate的新变量,并将其连接到计时器的Duration引脚,因此我们需要指定计时器在实例化子弹之前将等待多长时间。看看我们是如何将计时器的Completed引脚连接到检查我们是否有足够子弹实例化的If节点的;我们之前在这里连接到输入节点。
这里有一点小细节遗漏,那就是当我们按下键时,时间会流逝(fireRate),然后实例化一颗子弹,但之后就没有其他动作了。我们需要再次将节点序列的末尾(在这种情况下是AudioSource: Play节点)连接到计时器的Start引脚,以创建一个生成循环。当释放键时,这个循环将被中断,以防止它无限执行:

图 17.36:完成射击循环
最后,我们需要在输入节点中添加适当的Animator: SetBool(Name, Value)节点来开启和关闭布尔值并触发动画:

图 17.37:执行射击动画
现在我们已经处理了玩家的射击动画,接下来我们通过以下步骤来处理敌人的动画:
- 在EnemyFSM脚本中使用GetComponentInParent方法缓存父级动画器的引用,就像我们之前对NavMeshAgent所做的那样:

图 17.38:访问父级的 Animator 引用
- 在Shoot函数中打开Shooting动画参数,以确保每次射击时该参数都设置为true(选中):

图 17.39:打开射击动画
- 在所有非射击状态(如GoToBase和ChasePlayer)中关闭
Shooting参数:

图 17.40:关闭射击动画
- 关于视觉脚本版本,EnemyFSM中的GoToBase状态将如下所示:

图 17.41:GoToBase 状态
- 注意,我们再次需要GetParent节点来访问敌人的父Transform(即根),我们将它与Animator: SetBool节点连接起来,以便访问敌人根部的动画器。然后,ChasePlayer状态的动作将如下所示:

图 17.42:追逐玩家状态
- 然后,AttackBase和AttackPlayer的初始动作将如下所示:

图 17.43:攻击基础状态
通过这种方式,我们的玩家和敌人都有了恒定的射击行为和射击动画来反映这一点。现在让我们处理两者的移动动画。
编写移动动画脚本
对于动画控制器中的速度参数,我们可以检测刚体的速度向量的幅度,即每秒米数,并将其设置为当前值。这可以完美地与PlayerMovement脚本分离,因此如果需要,我们可以在其他场景中重用这个脚本。因此,我们需要创建一个如图所示的脚本,它只是将刚体组件的速度与动画器的速度参数连接起来,并将其添加到Player游戏对象中:

图 17.44:设置 VelocityAnimator 变量
关于视觉脚本版本,它看起来是这样的:

图 17.45:在视觉脚本中设置 Velocity Animator 变量
您可能需要增加到目前为止在动画控制器转换条件中使用的0.01转换阈值,因为刚体在释放按键后仍然在移动。对我来说,使用1效果很好。另一种选择是增加阻力和玩家的速度,使角色更快地停止。选择最适合您的方法。记住两层(UpperBody和LowerBody)的转换。
现在,我们可以将移动动画添加到敌人身上。为敌人预制件创建并添加一个名为NavMeshAnimator的脚本,它将获取其NavMeshAgent的当前速度并将其设置为动画控制器。这将与VelocityAnimator脚本类似,但这次检查的是NavMeshAgent的速度。我们没有使用VelocityAnimator,因为我们的 AI 不使用刚体来移动,所以它不起作用:

图 17.46:将 NavMeshAgent 连接到我们的 Animator Controller
可视脚本版本将如下所示:

图 17.47:将动画器速度参数设置为与我们的 NavMeshAgent 相同
注意,由于此图位于敌人的根对象旁边,与Animator和NavMeshAgent一起,所以我们不需要GetParent节点。有了这个,我们就已经编写了玩家和敌人的动画脚本。我们现在可以继续学习使用 Cinemachine 创建过场相机和其他更多内容。
使用 Cinemachine 创建动态相机
在视频游戏中,相机是一个非常重要的主题。它们允许玩家看到他们的周围环境,并根据他们所看到的内容做出决策。游戏设计师通常定义其行为以获得他们想要的精确游戏体验,这并不容易。必须分层很多行为才能获得精确的感觉。此外,对于过场动画,控制相机在动画期间将要穿越的路径以及相机在那些不断移动的场景中聚焦的动作也很重要。
在本章中,我们将使用Cinemachine包来创建两种相机:一种将跟随玩家动作的动态相机,我们将在第三部分中编写代码;另一种将在过场动画中使用的相机。
在本节中,我们将检查以下 Cinemachine 概念:
-
创建相机行为
-
创建轨道推车
让我们先讨论如何创建由 Cinemachine 控制的相机并配置其中的行为。
创建相机行为
Cinemachine 是一个技术库,包含了一系列可用于相机的不同行为,当正确组合时,可以生成视频游戏中所有常见的相机类型,包括从背后跟随玩家、第一人称相机、俯视相机等等。为了使用这些行为,我们需要理解大脑和虚拟相机的概念。
在 Cinemachine 中,我们只会保留一个主相机,就像我们迄今为止所做的那样,而这个相机将由虚拟相机控制,这些虚拟相机是具有上述行为的独立 GameObject。我们可以有多个虚拟相机,并且可以随意在它们之间切换,但活动的虚拟相机将是唯一一个将控制我们的主相机。这在游戏的不同点切换相机很有用,例如在玩家的第三人称相机和过场相机之间切换。为了使用虚拟相机控制主相机,它必须有一个Brain组件,该组件将监控所有活动的虚拟相机并选择适当的位姿来使用它们。
要开始使用 Cinemachine,首先,我们需要检查它是否已安装在包管理器中,就像我们之前在其他包中做的那样。如果你不记得如何做,只需按照以下步骤操作:
-
前往窗口 | 包管理器。
-
确保窗口左上角的Packages选项设置为Unity Registry:

图 17.48:包过滤器模式
-
等待左侧面板从服务器(需要互联网)填充所有包。
-
在列表中查找Cinemachine包并选择它。在撰写本书时,最新可用的版本是 2.8.6,但如果你愿意,可以使用更新的版本,始终确保以下步骤按预期工作;如果不按预期工作,你始终可以安装与我们最接近的版本。
-
如果你看到屏幕右下角的安装按钮,这意味着它尚未安装。只需点击该按钮即可。
现在我们已经安装了它,我们可以开始创建一个虚拟摄像机来跟随玩家。到目前为止,我们只是简单地将摄像机设置为玩家的子对象以便跟随,但现在我们将取消父化摄像机,让 Cinemachine 处理它,以学习如何使用此工具:
-
在玩家内部选择MainCamera并取消父化它(将其拖出玩家),使其成为场景的根对象,没有任何父对象。
-
点击GameObject | Cinemachine | Virtual Camera。这将创建一个名为
CM vcam1的新对象:

图 17.49:虚拟摄像机创建
- 如果你从Hierarchy面板中选择主摄像机,你也会注意到一个
CinemachineBrain组件已自动添加到它上,使我们的主摄像机跟随虚拟摄像机。尝试移动创建的虚拟摄像机,你会看到主摄像机是如何跟随它的:

图 17.50:CinemachineBrain 组件
- 选择虚拟摄像机(
CM vcam1)并将角色拖到CinemachineVirtualCamera组件的Follow和Look At属性。这将使移动和查看行为使用该对象来完成它们的工作:

图 17.51:设置摄像机的目标
- 你可以看到虚拟摄像机的Body属性设置为Transposer,这将使摄像机相对于在Follow属性中设置的目标移动——在我们的例子中,是角色。你可以打开Body选项(其左侧的箭头),更改Follow Offset属性,并将其设置为摄像机与目标之间的所需距离。在我的情况下,我使用了
0、3和-3的值:

图 17.52:从背后跟随角色的摄像机
-
图 17.50 显示了Game视图;你可以看到一个表示要观察的角色目标位置的小黄色矩形,它目前指向角色的基点——其脚部。如果你看不到它,请通过点击其左侧的箭头打开虚拟摄像机的Aim部分。
-
我们可以在虚拟摄像机的 Aim 部分的 Tracked Object Offset 属性中应用偏移。在我的案例中,
0、1.8和0的值工作得很好,使摄像机看向头部:

图 17.53:更改目标偏移量
如您所见,使用 Cinemachine 非常简单,在我们的案例中,默认设置大多数情况下已经足够满足我们需要的行为了。然而,如果您探索其他 Body 和 Aim 模式,您会发现您可以为任何类型的游戏创建任何类型的摄像机。本书中不会涵盖其他模式,但我强烈建议您查看 Cinemachine 的文档以了解其他模式的功能。要打开文档,请按照以下步骤操作:
-
通过转到 Window | Package Manager 打开 包管理器。
-
在左侧列表中找到 Cinemachine。如果它没有显示出来,请稍等片刻。请记住,你需要一个互联网连接才能使其工作。
-
一旦选择 Cinemachine,在右侧面板中向下滚动,直到您看到蓝色的 查看文档 链接。点击它:

图 17.54:Cinemachine 文档链接
- 您可以使用左侧的导航菜单探索文档:

图 17.55:Cinemachine 文档
与 Cinemachine 一样,您可以用相同的方式找到其他包的文档。现在我们已经实现了我们需要的相机基本行为,让我们探索如何使用 Cinemachine 为我们的开场剪辑创建一个相机。
创建摇臂轨道
当玩家开始关卡时,我们想要一个简短的场景,在进入战斗之前,摄像机在场景和基地上移动。这将需要摄像机跟随一个固定路径,这正是 Cinemachine 的摇臂摄像机所做的事情。它创建了一个路径,我们可以将其虚拟摄像机附加到它,使其跟随。我们可以设置 Cinemachine 自动通过轨道移动或跟随目标到轨道最近的点;在我们的案例中,我们将使用第一个选项。
为了创建摇臂摄像机,请按照以下步骤操作:
- 让我们从一个带有摇臂的轨道开始创建,这是一个沿着轨道移动的小对象,它将是跟随摄像机的目标。为此,点击 GameObject | Cinemachine | 带有摇臂的轨道:

图 17.56:一个默认直线路径的摇臂摄像机
-
如果您选择
DollyTrack1对象,您可以在 场景 视图中看到两个带有数字0和1的圆圈。这些是轨道的控制点。选择其中一个,并使用变换工具的箭头移动它,就像您移动其他对象一样。如果您看不到它们,请按 W 键以启用 变换 工具。 -
您可以通过点击
DollyTrack1对象的CinemachineSmoothPath组件的 Waypoints 列表底部的 + 按钮来创建更多的控制点:

图 17.57:添加路径控制点
- 创建您需要的航点数量,以创建一个路径,该路径将遍历您希望在开场剪辑场景中摄像机监督的区域。记住,您可以通过单击它们并使用平移辅助工具来移动航点:

图 17.58:我们场景的轨道车轨道。它正好在角色后面结束
-
创建一个新的虚拟摄像机。在创建后,如果您转到 游戏 视图,您会注意到角色摄像机将是激活的。为了测试新摄像机的效果,选择之前的摄像机(CM vcam1),并通过在检查器中单击 GameObject 名称左侧的复选框暂时禁用它。
-
这次将 跟随 目标设置为之前创建的带有轨道的
DollyCart1对象。 -
将 身体 部分的 跟随偏移 设置为
0,0,和0以保持摄像机与车的同一位置。 -
将 目标 设置为 与跟随目标相同,使摄像机朝与车相同的方向看,这将跟随轨道曲线:

图 17.59:配置使虚拟摄像机跟随轨道车的设置
- 选择 DollyCart1 对象,并更改 位置 值以查看车如何在轨道上移动。在游戏窗口聚焦且 CM vcam2 处于独奏模式时进行此操作,以查看摄像机的效果:

图 17.60:轨道车组件
- 重新启用
CM vcam1。
在正确设置轨道车后,我们可以使用 Timeline 来创建我们的剪辑场景,并对其进行排序。
使用 Timeline 创建剪辑场景
我们有我们的开场摄像机,但这不足以创建一个剪辑场景。一个合适的剪辑场景是一系列在它们应该发生的确切时刻发生的动作,协调多个对象以实现预期行为。我们可以有诸如启用和禁用对象、切换摄像机、播放声音、移动对象等动作。为此,Unity 提供了 Timeline,这是一个动作序列器,用于协调这类剪辑场景。我们将使用 Timeline 为我们的场景创建一个开场剪辑场景,展示游戏开始前的关卡。
在本节中,我们将检查以下 Timeline 概念:
-
创建动画剪辑
-
排序我们的开场剪辑场景
我们将了解如何在 Unity 中创建自己的动画剪辑来动画化我们的 GameObject,然后将它们放置在剪辑场景中,以使用 Timeline 序列器工具协调它们的激活。让我们先创建一个摄像机动画,稍后将在 Timeline 中使用。
创建动画剪辑
这实际上不是一个 Timeline 特定的功能,而是一个 Unity 功能,它与 Timeline 配合得很好。当我们下载角色时,它附带了一些使用外部软件创建的动画剪辑,但你也可以使用 Unity 的Animation窗口创建自定义动画剪辑。不要将其与允许我们创建对游戏情况做出反应的动画转换的Animator窗口混淆。这有助于创建小对象特定的动画,你将在 Timeline 中稍后与其他对象的动画进行协调。
这些动画可以控制对象组件属性的任何值,例如位置、颜色等。在我们的例子中,我们想要动画化娃娃轨道的Position属性,使其在给定时间内从起点到终点移动。为了做到这一点,请执行以下操作:
-
选择
DollyCart1对象。 -
通过Window | Animation | Animation进入Animation(不是Animator)窗口。
-
点击Animation窗口中央的Create按钮。记住,在选中娃娃车(而不是轨道)时进行此操作:

图 17.61:创建自定义动画剪辑
- 完成这个操作后,你会被提示将动画剪辑保存到某个地方。我建议你在项目(在
Assets文件夹内)中创建一个Animations文件夹,并将其命名为IntroDollyTrack。
如果你注意的话,现在的娃娃车现在有一个Animator组件,并且创建了一个 Animator Controller,其中包含了我们刚刚创建的动画。就像任何动画剪辑一样,你需要使用 Animator Controller 将其应用到你的对象上;自定义动画也不例外。所以,Animation窗口为您创建了它们。
在此窗口中动画制作包括指定其属性在给定时刻的值。在我们的例子中,我们希望动画开始时在时间轴上的 0 秒,Position 的值为 0,在动画结束时在 5 秒,值为 254。我选择 254 是因为那是我的车上的最后一个可能的位置,但这取决于你的娃娃轨道的长度。只需测试一下你的最后一个可能的位置。此外,我选择5秒,因为这是我认为动画的正确长度,但你可以随意更改它。现在,动画的 0 到 5 秒之间发生的事情是 0 和 254 值之间的插值,这意味着在 2.5 秒时,Position 的值将是 127。动画总是由不同时刻我们对象的不同状态之间的插值组成。
为了做到这一点,请按照以下步骤操作:
-
在Animation窗口中,点击记录按钮(左上角的部分中的红色圆圈)。这将使 Unity 检测我们对象中的任何变化并将它们保存到动画中。记住,在选中娃娃车时进行此操作。
-
将轨道车的位置设置更改为
1然后0。将此值更改为任何值然后再次更改为0将创建一个关键帧,这是一个动画中的点,表示在0秒时,我们希望位置值为0。如果值已经为0,我们需要首先将其设置为任何其他值。
你会注意到位置属性已经被添加到动画中:

图 17.62:将位置值更改为 0 后的记录模式动画
- 使用鼠标滚轮,将动画窗口右侧的时间轴放大,直到你在顶部栏中看到5:00秒:

图 17.63:动画窗口的时间轴显示 5 秒
-
点击时间轴顶部栏的5:00秒标签,将播放头定位在那个时刻。这将定位我们接下来在那个时刻所做的更改。
-
将轨道的位置值设置为你可以得到的最高值;在我的情况下,这是
240。请记住,将动画窗口置于记录模式:

图 17.64:在动画 5 秒处创建具有 240 值的帧
- 点击动画窗口左上角的播放按钮以查看动画播放。请记住,在
CM vcam1禁用的情况下,在游戏视图中查看。
现在,如果我们点击播放,动画将开始播放,但这不是我们想要的。在这种情况下,我们的想法是将场景的控制权交给场景系统,即时间轴,因为我们的场景中需要排序的不仅仅是这个动画。防止动画器组件自动播放我们创建的动画的一种方法是在控制器中创建一个空动画状态,并按照以下步骤将其设置为默认状态:
-
搜索我们创建动画时同时创建的动画控制器,并打开它。如果你找不到,只需选择轨道车,并在我们的 GameObject 上的动画器组件的控制器属性上双击以打开资产。
-
在控制器中右键单击一个空状态,然后选择创建状态 | 空。这将像创建一个新动画一样在状态机中创建一个新的状态,但这次它是空的:

图 17.65:在动画控制器中创建空状态
- 右键单击新状态,然后点击设置为层默认状态。状态应该变为橙色:

图 17.66:将控制器的默认动画更改为空状态
- 现在,如果你点击播放,由于我们的轨道车默认状态为空,将不会播放任何动画。在这种情况下不需要过渡。
现在我们已经创建了我们的摄像机动画,让我们开始创建一个场景切换,从开场场景摄像机切换到玩家摄像机,通过使用时间轴来实现。
排序我们的开场场景
时间轴已安装到您的项目中,但如果您进入时间轴的包管理器,您可能会看到一个更新按钮,如果您需要一些新功能,可以获取最新版本。在我们的例子中,我们将保持项目中包含的默认版本(1.5.2,本书编写时)。
我们首先要做的是创建一个场景资产和一个负责播放它的场景对象。为此,请按照以下步骤操作:
-
使用GameObject | 创建空对象选项创建一个空 GameObject。
-
选择空对象并将其命名为
Director。 -
打开窗口 | 序列 | 时间轴以打开时间轴编辑器。
-
当导演对象被选中时,点击时间轴窗口中间的创建按钮,将那个对象转换为场景播放器(或导演)。
-
完成此操作后,将弹出一个窗口,要求您保存文件。此文件将是场景或时间轴;每个场景都将保存在自己的文件中。将其保存在您项目中的
Cutscenes文件夹(Assets文件夹)中。 -
现在,您可以看到导演对象具有一个可播放导演组件,其中包含在上一步骤中保存的Intro场景资产设置为可播放属性,这意味着此场景将由导演播放:

图 17.67:准备播放 Intro 时间轴资产的可播放导演
现在我们有了准备工作的时间轴资产,让我们制作序列动作。首先,我们需要序列化两件事——首先,我们在上一步中完成的摇臂车位置动画,然后是摇臂轨道相机(CM vcam2)和玩家相机(CM vcam1)之间的相机交换。正如我们之前所说的,场景是一系列在给定时刻执行的动作,为了安排动作,您将需要轨道。在时间轴中,我们有不同类型的轨道,每种轨道都允许您在特定对象上执行某些动作。我们将从动画轨道开始。
动画轨道将控制特定对象将播放哪个动画;我们需要为每个对象创建一个轨道来动画化。在我们的例子中,我们希望摇臂轨道播放我们创建的Intro动画,所以让我们按照以下步骤操作:
- 通过点击加号按钮 (+)然后选择动画轨道来添加一个动画轨道:

图 17.68:创建动画轨道
-
选择导演对象,并在检查器窗口中检查可播放导演组件的绑定列表。
-
将Cart对象拖动以指定我们想要动画轨道控制其动画:

图 17.69:使动画轨道控制本导演中的摇臂车动画
时间轴是一个通用的资产,可以应用于任何场景,但由于轨道控制特定的对象,您需要手动在每个场景中绑定它们。在我们的例子中,我们有一个动画轨道,它期望控制单个动画师,因此在每个场景中,如果我们想应用这个场景,我们需要将特定的动画师拖动到绑定列表中控制它。
- 将我们创建的Intro动画资产拖动到时间轴窗口中的动画轨道。这将在轨道中创建一个剪辑,显示动画何时以及播放多长时间。您可以将尽可能多的动画拖动到轨道中,以便在不同的时刻播放不同的动画,但现在我们只想播放一个:

图 17.70:使动画师轨道播放 intro 剪辑
-
您可以将动画拖动以更改它播放的确切时刻。将其拖动到轨道的开始位置。
-
在时间轴窗口的右上角点击播放按钮以查看其效果。您还可以手动在时间轴窗口中拖动白色箭头,以在不同的时刻查看场景。如果不起作用,请尝试玩游戏然后停止:

图 17.71:播放时间轴并拖动播放头
-
现在,我们将使我们的Intro时间轴资产告诉
CinemachineBrain组件(主摄像头)在场景的每个部分中哪个摄像头将是活动的,摄像头动画结束后切换到玩家摄像头。我们将创建第二个轨道——一个 Cinemachine 轨道,它专门用于在特定的CinemachineBrain组件之间切换不同的虚拟摄像头。为此,请按照以下步骤操作: -
再次点击+按钮,然后点击Cinemachine 轨道。请注意,您可以在不安装Cinemachine的情况下安装时间轴,但那种情况下此类轨道将不会显示:

图 17.72:创建新的 Cinemachine 轨道
- 在可播放导演组件的绑定列表中,将主摄像头拖动到Cinemachine 轨道,以便该轨道控制在不同时刻的场景中哪个虚拟摄像头将控制主摄像头:

图 17.73:将主摄像头绑定到 Cinemachine 轨道
- 下一步指示在时间轴的特定时刻哪个虚拟摄像头将是活动的。为此,我们的 Cinemachine 轨道允许我们将虚拟摄像头拖动到它上面,这将创建虚拟摄像头剪辑。按照顺序,将CM vcam2和CM vcam1拖动到 Cinemachine 轨道:

图 17.74:将虚拟摄像头拖动到 Cinemachine 轨道
-
如果您点击播放按钮或只是拖动时间轴播放头,您可以看到当播放头到达第二个虚拟摄像头剪辑时,活动虚拟摄像头如何变化。请记住在游戏视图中查看。
-
如果您将鼠标放在剪辑的末端附近,会出现一个调整大小光标。如果您拖动它们,可以调整剪辑的大小以指定它们的持续时间。在我们的例子中,我们需要将
CM vcam2剪辑的长度与Cart动画剪辑匹配,然后通过拖动将其放在末尾,这样当推车动画结束时,摄像机就会处于活动状态。在我的情况下,它们的长度已经相同,但还是要尝试改变它以练习。此外,您还可以使CM vcam1剪辑更短;我们只需要播放几秒钟来执行摄像机切换。 -
您也可以稍微重叠一下剪辑,以便在两个摄像机之间实现平滑过渡,而不是生硬切换,这样看起来会比较奇怪:

图 17.75:调整大小和重叠剪辑以进行插值
- 将WaveSpawners的开始时间属性增加,以防止在剪辑场景开始之前生成敌人。
如果您等待整个剪辑场景结束,您会注意到在最后,CM vcam2再次变得活跃。您可以配置 Timeline 如何处理剪辑场景的结束,默认情况下,它什么都不做。这可能会导致根据轨道类型的不同而出现不同的行为——在我们的例子中,再次将选择虚拟摄像机的控制权交给CinemachineBrain组件,该组件将选择具有最高优先级值的虚拟摄像机。我们可以更改虚拟摄像机的优先级属性,以确保CM vcam1(玩家摄像机)始终是更重要的一方,或者将可播放导演组件的包裹模式设置为保持,这将保持一切如时间轴的最后一帧所指定。在我们的例子中,我们将使用后者选项来测试 Timeline 特定的功能:

图 17.76:包裹模式设置为保持模式
大多数不同类型的轨道都遵循相同的逻辑;每个轨道都会使用在特定时间内执行的剪辑来控制特定对象的特定方面。我鼓励您测试不同的轨道,看看它们的作用,例如激活,它可以在剪辑场景中启用和禁用对象。记住,您可以在包管理器中查看 Timeline 包的文档。
摘要
在本章中,我们介绍了 Unity 为不同需求提供的不同动画系统。我们讨论了导入角色动画以及使用动画控制器来控制它们。我们还看到了如何制作能够对游戏当前情况进行反应的摄像机,例如玩家的位置,或者可以在剪辑场景中使用。最后,我们探讨了 Timeline 和动画系统,为我们的游戏创建了一个开场剪辑场景。这些工具对于让我们的团队动画师直接在 Unity 中工作非常有用,无需整合外部资产(除了角色动画)的麻烦,同时也防止程序员创建重复的脚本来创建动画,从而节省时间。
现在,你可以在 Unity 中导入和创建动画片段,并将它们应用到 GameObject 上,使它们根据片段移动。此外,你还可以将它们放置在 Timeline 序列编辑器中,以协调它们并为你的游戏创建场景。最后,你可以创建动态相机,用于游戏或场景中。
有了这些,我们就结束了第二部分,在这一部分中,我们学习了不同的 Unity 系统,以提升我们游戏的艺术表现力。在下一章,即第三部分的第一章中,我们将总结我们游戏的发展,了解如何构建和优化我们的游戏,并提供增强现实应用的快速介绍。
第十八章:使用 Profiler、帧调试器和内存 Profiler 进行优化
欢迎来到本书的第四部分——我很高兴你到达了这一部分,这意味着你几乎完成了一个完整游戏!在本章中,我们将讨论优化技术来审查你的游戏性能并提高它,因为保持良好的和稳定的帧率对任何游戏都是至关重要的。
性能是一个广泛的话题,需要深入理解几个 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 性能。在我们的游戏中不会出现这种情况,所以我们现在不考虑这一点。
让我们通过以下步骤使用帧调试器分析我们游戏的渲染过程:
-
打开帧调试器(窗口 | 分析 | 帧调试器)。
-
游戏并当您想要分析性能时,点击帧调试器(启用)左上角的启用按钮(在游戏过程中按Esc键可以恢复鼠标控制):

图 18.2:启用帧调试器
-
点击游戏选项卡以打开游戏视图。
-
将禁用按钮右侧的滑块从左到右缓慢移动,以查看场景的渲染方式。每一步都是一个在 CPU 上为该游戏帧执行的绘制调用。您还可以观察窗口左侧的列表如何突出显示当时正在执行的绘制调用的名称:

图 18.3:分析我们的帧的绘制调用
-
如果列表中的某些绘制调用在游戏面板中输出灰色图像,同时在控制台出现警告,则此问题的临时解决方案是选择场景的主相机,并将其MSAA属性在相机组件的输出部分设置为关闭。请记住,之后使用帧调试器撤销此更改。
-
点击列表中的任何绘制调用,并观察窗口右侧的详细信息。
如果您不习惯于代码引擎或着色器,其中大部分可能会让您感到困惑,但您可以看到其中一些有可读的部分,说明为什么这个绘制调用不能与上一个一起批处理,这告诉您为什么两个对象没有在单个绘制调用中一起绘制。我们将在稍后检查这些原因:

图 18.4:帧调试器中的批处理中断原因
- 在播放模式下打开窗口,禁用地面,并立即查看绘制调用数量的变化。有时,仅仅打开或关闭对象就足以检测导致性能问题的原因。还可以尝试禁用后期处理和其他与图形相关的对象,如粒子。
即使我们对每个绘制调用来自哪里并不完全清楚,我们也可以至少从修改 Unity 中的设置开始,看看这些变化的影响。没有比通过每个切换并查看测量工具中这些变化的影响更好的方式来发现像 Unity 这样庞大的东西是如何工作的。当然,有时我们可能需要付出某些绘制调用的代价来实现某些效果,比如在地面场景中,尽管你总是可以怀疑这是否值得,但这需要逐个案例进行分析。
即使帧调试器给我们提供了大量信息,有时你可以额外采取一步,并使用更高级的工具,如 RenderDoc 或 Nvidia Nsight 等,这些工具在功能上与帧调试器相似,即它们显示了所有的绘制调用,但还显示了每个绘制调用的计时信息、每个调用使用的网格、着色器和纹理等信息。
现在,让我们讨论减少绘制调用的基本技术,并查看它们在帧调试器中的效果。
使用批处理
在前几章中,我们讨论了几种优化技术,其中光照是最重要的。如果你在实现这些技术时测量绘制调用,你会注意到这些操作对绘制调用数量的影响。然而,在本节中,我们将关注另一种称为批处理的图形优化技术。批处理是将多个对象分组以在单个绘制调用中一起绘制的进程。
你可能想知道为什么我们不能只在一个绘制调用中绘制所有内容,虽然这在技术上可行,但为了合并两个对象,需要满足一系列条件,通常情况是合并材质。
记住,材料是作为图形配置文件使用的资产,它指定了一个材质模式或着色器以及一组参数来定制我们对象的外观,并且记住我们可以在多个对象中使用相同的材质。如果 Unity 需要绘制一个与上一个不同的材质的对象,必须在发出绘制调用之前调用SetPass,这是另一种形式的 CPU/GPU 通信,用于在 GPU 中设置材质属性,例如其纹理和颜色。如果两个对象使用相同的材质,则可以跳过此步骤。第一个对象的SetPass调用会被第二个对象重用,这为批处理对象打开了机会。如果它们共享相同的设置,Unity 可以在 CPU 中将网格合并成一个,然后通过单个绘制调用将合并后的网格发送到 GPU。
有几种方法可以减少材质的数量,例如删除重复项,但最有效的方法是通过一个称为纹理图集的概念。这意味着将不同对象的纹理合并到一个纹理中。这样,由于使用的纹理可以应用于多个对象,因此多个对象可以使用相同的材质。遗憾的是,Unity 中没有自动系统来合并三维对象的纹理,例如我们在 2D 中使用的 Texture Atlas 对象。可能有一些系统在 Asset Store 中,但自动系统可能会有一些副作用。这项工作通常由艺术家完成,所以当与专门的 3D 艺术家(或如果你是自己)工作时,请记住这个技术:

图 18.5:不同金属物体的碎片
通过以下操作使用帧调试器探索批处理:
- 前往编辑 | 首选项 | 核心渲染管线并将可见性设置为所有可见。这将允许我们看到基本和高级图形设置:

图 18.6:启用显示所有可用图形设置
- 搜索我们目前想要使用的可脚本渲染管线设置资产(编辑 | 项目设置 | 图形 | 可脚本渲染管线设置):

图 18.7:可脚本渲染管线设置
- 在渲染部分取消勾选SRP Batcher并勾选动态批处理。我们将在本章后面讨论SRP Batcher:

图 18.8:禁用 SRP Batcher
-
为测试创建一个新的空场景(文件 | 新建场景)。
-
创建两种不同颜色的材质。
-
创建两个立方体,并将一个材质放入第一个立方体,另一个材质放入第二个立方体。
-
打开帧调试器并点击启用以查看我们立方体的绘制调用列表:

图 18.9:立方体的绘制调用
-
选择第二个绘制网格立方体调用,并查看批处理中断的原因。它应该说明对象具有不同的材质。
-
在两个立方体上使用相同的材质,并再次查看列表。你会注意到现在我们只有一个绘制网格立方体调用。如果你没有在玩游戏,可能需要再次禁用和启用帧调试器以正确刷新。
现在,我挑战你尝试相同的步骤,但用球体代替立方体。如果你这样做,你可能会注意到,即使使用相同的材质,球体也没有被批处理!这就是我们需要引入动态批处理概念的地方。
记住,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 的另一个好处是,由于顶点数量的减少,可以降低绘制调用的成本。
要使用此功能,请执行以下操作:
- 创建一个空对象,并将模型的两个版本作为父对象。你需要使用具有不同细节级别的好几个版本,但现在我们只是使用一个立方体和一个球体来测试这个功能:

图 18.12:一个具有两个 LOD 网格的单个对象
-
将LOD 组组件添加到父对象中。
-
默认的LOD 组已准备好支持三个 LOD 网格组,但因为我们只有两个,所以右键单击一个,然后点击删除。您也可以选择插入之前来添加更多 LOD 组:

图 18.13:移除 LOD 组
-
选择LOD 0,这是最高细节的 LOD 组,然后点击下面的渲染器列表中的添加按钮,将该球体添加到该组。你可以添加任意数量的网格渲染器。
-
选择LOD 1并添加立方体:

图 18.14:向 LOD 组添加渲染器
-
拖动两个组之间的线来控制每个组将占据的距离范围。当你拖动时,你会看到相机需要移动多远才能切换组。此外,你还有裁剪组,这是相机不会渲染任何组的距离。
-
只需在场景面板中移动场景,就可以看到网格是如何交换的。
-
这里需要考虑的是,物体的碰撞器不会被禁用,所以只需在 LOD 子对象中放置渲染器。将 LOD 0 形状的碰撞器放在父对象中,或者只需从 LOD 组对象中移除碰撞器,除了组 0。
另一个需要考虑的优化是视锥裁剪。默认情况下,Unity 会渲染任何落在摄像机视图区域或视锥体内的对象,跳过那些不在的对象。该算法足够便宜,可以始终使用,而且无法禁用它。然而,它确实有一个缺陷。如果我们有一堵墙隐藏了它后面的所有对象,即使它们被遮挡,它们也会落在视锥体内,所以它们仍然会被渲染。检测网格的每个像素是否遮挡了另一个网格的每个像素几乎是不可能在实时中完成的,但幸运的是,我们有一个解决方案:遮挡裁剪。
遮挡剔除是一个分析场景并确定场景不同部分中可以看到哪些对象的过程,将它们分为区域并分析每一个。由于这个过程可能需要相当长的时间,它是在编辑器中完成的,类似于光照贴图。正如你可以想象的那样,它只对静态对象有效,因为它的计算是在编辑器中进行的。要使用它,请执行以下操作:
-
将不应移动的对象标记为静态,或者如果你只想让这个对象在遮挡剔除系统中被视为静态,请检查静态复选框右侧的箭头处的遮挡者静态和被遮挡者静态复选框。
-
打开遮挡剔除窗口(窗口 | 渲染 | 遮挡剔除)。
-
保存场景并点击窗口底部的烘焙按钮,然后等待烘焙过程。如果你在烘焙过程之前没有保存场景,它将不会执行。
-
在遮挡剔除窗口中选择可视化选项卡。
-
当遮挡剔除窗口可见时,选择相机(或 Cinemachine 控制的相机的情况下的虚拟相机)并拖动它,观察随着相机移动,物体是如何被遮挡的:

图 18.15:左侧是正常场景,右侧是应用了遮挡剔除的场景
请注意,如果你将相机移动到计算区域之外,该过程将不会进行,Unity 将只计算静态对象附近的区域。你可以通过创建一个空对象并添加遮挡区域组件来扩展计算区域,设置其位置和大小以覆盖相机将到达的区域,最后重新烘焙剔除。尽量对立方体的尺寸保持敏感。要计算的区域越大,你磁盘上存储生成的数据所需的空间就越大。
你可以使用这些区域中的几个来提高精确度——例如,在一个 L 形场景中,你可以使用其中的两个:

图 18.16:遮挡区域
如果你看到物体没有被遮挡,可能是因为遮挡对象(在这个例子中是墙壁)不够大,不能被考虑。你可以增加对象的大小或减少窗口烘焙选项卡中的最小遮挡者设置。这样做将进一步细分场景以检测小遮挡者,但这将在磁盘上占用更多空间来存储更多数据。因此,再次提醒,对这个设置保持敏感。
尽管我们还可以应用一些其他技术到我们的游戏中,但我们讨论的这些已经足够用于我们的游戏。因此,在本节中,我们学习了在视频卡中渲染图形的过程,批处理的概念,如何分析它们以确切知道我们有多少个批处理以及它们在做什么,最后,如何尽可能减少它们。现在,让我们开始讨论其他优化领域,例如处理领域。
优化处理
虽然图形通常占用生成帧所需的大部分时间,但我们绝不能低估代码和场景优化不良的成本。游戏中有几个部分仍在 CPU 中进行计算,包括图形处理的一部分(如批处理计算)、物理、音频以及我们的代码。在这里,我们比图形方面有更多的性能问题原因,所以,我们再次不讨论每一个优化,而是学习如何发现它们。
在本节中,我们将探讨以下 CPU 优化概念:
-
检测 CPU 和 GPU 限制
-
使用CPU 使用情况Profiler
-
通用 CPU 优化技术
我们将首先讨论 CPU 和 GPU 限制的概念,这些概念侧重于优化过程,确定问题是否与 GPU 或 CPU 相关。稍后,就像 GPU 优化过程一样,我们将探讨如何收集 CPU 的性能数据并解释它以检测可能要应用的优化技术。
检测 CPU 和 GPU 限制
与帧调试器类似,Unity Profiler 允许我们通过一系列 Profiler 模块收集关于游戏性能的数据,每个模块都设计用来收集每帧不同 Unity 系统的数据,例如物理、音频,最重要的是CPU 使用情况。这个最后的模块允许我们看到 Unity 执行以处理帧的最重要操作——从我们的脚本到物理和图形(CPU 部分)等系统。
在探索CPU 使用情况之前,我们可以在本模块中收集的一个重要数据点是我们是否受 CPU 或 GPU 限制。正如之前所解释的,一个帧的处理既使用 CPU 也使用 GPU,这些硬件可以并行工作。当 GPU 执行绘图命令时,CPU 可以非常高效地执行物理和我们的脚本。但是,现在假设 CPU 完成其工作,而 GPU 仍在工作。CPU 可以开始处理下一帧吗?答案是不了。这会导致不同步,因此在这种情况下,CPU 需要等待。这被称为 CPU-bound,我们还有相反的情况,即 GPU-bound,当 GPU 比 CPU 先完成时。
集中我们的优化努力是很重要的,因此如果我们检测到我们的游戏是 GPU 限制的,我们将专注于 GPU 图形优化(如减少网格和着色器复杂性),如果是 CPU 限制的,那么我们将专注于其他系统和图形处理的 CPU 部分。为了检测我们的游戏是哪种情况,请执行以下操作:
-
打开 分析器(窗口 | 分析 | 分析器)。
-
在左上角的 分析器模块 下拉菜单中,勾选 GPU 以启用 GPU 分析器:

图 18.17:启用 GPU 分析器
-
播放游戏并选择 CPU 使用率 分析器,点击 分析器 窗口的左侧部分其名称。
-
点击 最后一帧 按钮,即指向右的双箭头按钮,以始终显示正在渲染的最后一帧的信息:

图 18.18:最后一帧按钮(向右的双箭头)
- 还点击 Live 按钮以启用实时模式,这允许你实时查看分析结果。这可能会影响性能,因此你可以稍后禁用它:

图 18.19:启用实时模式
- 观察窗口中间带有 CPU 和 GPU 标签的条形图。它应该说明 CPU 和 GPU 消耗了多少毫秒。数值较高的那个将是限制我们的帧率的那个,并确定我们是 GPU-还是 CPU-限制的:

图 18.20:确定我们是 CPU-还是 GPU-限制的
- 有可能当你尝试打开 GPU 分析器时,你会看到一个不支持的消息,这种情况可能发生在某些情况下(例如在使用 Metal 图形 API 的 Mac 设备上)。在这种情况下,另一种查看我们是否是 GPU 限制的方法是在选择 CPU 使用率 分析器时,在 CPU/GPU 标签旁边的搜索栏中搜索
waitforpresent。如果你看不到搜索栏,点击 Live(应显示为 时间轴)左侧的下拉菜单并选择 层次结构:

图 18.21:搜索 waitforpresent
- 在这里,你可以看到 CPU 等待 GPU 的时间有多长。检查 时间 ms 列以获取数字。如果你看到 0.00,这意味着 CPU 不在等待 GPU,这意味着我们是 CPU-限制的。在前面的屏幕截图中,你可以看到我的屏幕显示 0.00,而 CPU 正在消耗 9.41ms,GPU 正在消耗 6.73ms。所以,我的设备是 CPU-限制的,但请考虑你的设备和项目可能带来不同的结果。
现在我们能够检测到我们是 CPU 受限还是 GPU 受限,我们可以集中优化努力。到目前为止,我们在优化图形部分讨论了如何分析和优化 GPU 过程的一部分。现在,如果我们检测到我们是 CPU 受限,让我们看看如何分析 CPU。
使用 CPU 使用率分析器
分析 CPU 的方式与分析 GPU 的方式类似。我们需要获取 CPU 执行的动作列表,并尝试减少它们的数量,或者至少减少它们的成本。在这里,CPU 使用率分析器模块就派上用场了——这是一个允许我们查看 CPU 在一个帧中执行的所有指令的工具。主要区别在于 GPU 主要执行绘制调用,我们只有几种类型,而 CPU 可以执行数百种不同的指令,有时其中一些指令无法删除,例如物理或音频处理。在这些情况下,我们希望减少这些函数的成本,以防它们消耗了太多时间。因此,在这里的一个重要提示是检测哪个函数消耗了太多时间,然后减少其成本或删除它,这需要更深入地了解底层系统。让我们首先开始检测函数。
当你在打开分析器标签的情况下玩游戏时,你会看到一系列显示游戏性能的图形,在CPU 使用率分析器中,你会看到图形被分成不同的颜色,每个颜色都指代帧处理的不同部分。你可以查看分析器左侧的信息,以了解每种颜色的含义,但让我们讨论最重要的几个。
在下面的屏幕截图中,你可以看到图形应该如何显示:

图 18.22:分析 CPU 使用率图
如果你看到图形,你可能会认为图表中深绿色的部分占据了大部分的性能时间,虽然这是真的,但你也可以从图例中看到,深绿色代表其他,这是因为我们在编辑器中分析游戏。编辑器不会表现得完全像最终的游戏。为了运行它,它必须执行许多额外的处理,这些处理在游戏中不会执行,所以你能做的最好的事情就是直接在游戏构建中进行分析。在那里,你会收集到更准确的数据。我们将在下一章讨论如何进行构建,所以现在我们可以忽略那个区域。我们现在能做的就是简单地点击其他标签左侧的彩色方块,以从图中禁用该测量,以便稍微清理一下。如果你也看到一个很大的黄色部分,它指的是垂直同步,这基本上是我们等待处理与显示器刷新率匹配的时间。这也是我们可以忽略的东西,所以你也应该禁用它。在下一张屏幕截图中,你可以检查图形颜色类别以及如何禁用它们:

图 18.23:从性能分析器中禁用 VSync 及其他设置
现在我们已经清理了图表,我们可以通过查看带有ms标签的线条(在我们的案例中,5ms (200FPS))来了解我们游戏潜在帧率的良好概念,这表明低于该线的帧有超过 200 FPS,而高于该线的帧则较少。
在我的情况下,我拥有出色的性能,但请记住,我是在一台强大的机器上测试的。进行性能分析的最佳方式不仅是在游戏的构建(作为可执行文件)中,还包括在目标设备上,这应该是我们打算让游戏运行的最低配置硬件。我们的目标设备很大程度上取决于游戏的目标受众。如果我们正在制作休闲游戏,我们可能针对的是移动设备,因此我们应该在最低配置的手机上测试游戏,但如果我们的目标是针对核心玩家,他们可能拥有强大的机器来运行我们的游戏。
如果你针对的是核心玩家,当然,这并不意味着我们可以因为这一点就制作一个非常未优化的游戏,但这将给我们足够的空间来添加更多细节。无论如何,我强烈建议如果你是初学者,避免那些类型的游戏,因为它们更难开发,你可能很快就会意识到这一点。一开始就坚持简单的游戏。
通过观察图形颜色,你可以观察到渲染在 CPU 方面的成本以浅绿色表示,图表显示它占用了相当一部分的处理时间,这是正常的。然后,在蓝色中,我们可以看到我们的脚本和其他系统执行的代价,这也占用了相当一部分,但同样,这也是相当正常的。我们还可以观察到一点橙色,代表物理,还有一点浅蓝色,代表动画。请记住检查性能分析器中的彩色标签,以记住哪种颜色代表什么。
现在,那些彩色条代表一组操作,如果我们认为渲染条代表 10 个操作,我们如何知道这包括哪些操作?同样,我们如何知道这些操作中哪一个占用了最多的性能时间?在这 10 个操作中,任何一个都可能是导致这些问题的原因。这就是性能分析器底部部分有用的地方。它显示了一帧中所有被调用的函数列表。要使用它,请执行以下操作:
-
点击性能分析器中CPU 使用率部分的任何部分,并检查性能分析器底部栏左上角的按钮是否显示为层次结构。如果不是(例如,如果显示为时间线),点击它并选择层次结构。
-
清除我们之前使用的搜索栏。它将按名称过滤函数调用,而我们希望看到所有调用。
-
点击时间 ms列,直到你看到一个指向下方的箭头。这将按成本降序排列调用。
-
点击图表中引起你注意的帧——可能是那些高度最大、消耗更多处理时间的帧之一。这将使 Profiler 立即停止游戏并显示有关该帧的信息。
在查看图表时,有两个方面需要考虑。如果你看到高于其他帧的峰值,这可能会在游戏中造成中断——一个非常短暂的瞬间,游戏会冻结,这可能会破坏性能。此外,你可以寻找长时间消耗较高的帧序列。尝试减少它们。即使这只是一个临时的解决方案,但玩家很容易就能感知到它的影响,尤其是在 VR 游戏中,因为这可能会引起恶心。
-
PlayerLoop 可能会显示为耗时最长的帧,但这并不很有信息量。你可以通过点击其左侧的箭头进一步探索它。
-
点击每个函数以在图表中突出显示它。处理时间较长的函数将以较粗的条形显示,我们将重点关注这些函数:

图 18.24:图中突出显示的渲染相机功能
- 你可以继续点击箭头以进一步探索函数,直到达到限制。如果你想深入了解,可以在 Profiler 的顶部栏中启用深度分析模式。这将提供更多细节,但请注意,这个过程成本高昂,会使游戏运行变慢,改变图表中显示的时间,使其看起来比实际时间高得多。在这里,忽略数字,根据图表查看函数占用的过程量。你需要停止,启用深度分析,然后再次播放以使其工作:

图 18.25:启用深度分析
借助这些知识,我们可以开始提高我们的游戏性能(如果它低于目标帧率),但每个函数都是由 CPU 调用的,并且以它独特的方式得到改进,这需要我们对 Unity 内部工作有更深入的了解。这可能需要几本书的内容,而且无论如何,内部结构会随着版本的不同而变化。相反,你可以通过在网上查找关于该特定系统的数据以及官方文档来研究每个函数的工作原理,或者再次,通过禁用和启用我们的代码或其部分来探索我们行动的影响,就像我们在帧调试器中所做的那样。性能分析需要创造力和推理来解释和相应地反应所获得的数据,因此你需要一些耐心。
既然我们已经讨论了如何获取与 CPU 相关的性能分析数据,那么让我们来讨论一些常见的降低CPU 使用率的方法。
通用 CPU 优化技术
在 CPU 优化方面,有许多可能导致性能高的原因,包括滥用 Unity 的功能、大量物理或音频对象、不正确的资产/对象配置等。我们的脚本也可以以非优化的方式编写,滥用或误用昂贵的 Unity API 函数。到目前为止,我们已经讨论了使用 Unity 系统的几个良好实践,例如音频配置、纹理大小、批处理,以及将GameObject.Find等函数替换为管理器。因此,让我们讨论一些常见情况的具体细节。
让我们从观察大量对象如何影响我们的性能开始。在这里,您只需创建大量带有Rigidbody(至少 200 个)的对象,并在动态配置文件中配置,然后在剖析器中观察结果。
在下面的屏幕截图中,您会注意到,剖析器的橙色部分变大了,而Physics.Processing函数是导致这种增加的原因:

图 18.26:多个对象的物理处理
请记住,剖析器还有其他可以通过点击剖析器模块按钮激活的模块,其中一个用于物理。考虑启用它并检查它提供的信息。还要查看剖析器的官方文档,以获取有关这些模块的更多信息。
另一个测试多个对象影响的方法是创建大量的音频源。在下面的屏幕截图中,您可以看到我们需要重新启用其他,因为部分音频处理属于该类别。我们之前提到其他属于编辑器,但它也可以包括其他过程,所以请记住这一点:

图 18.27:多个对象的物理处理
因此,为了发现这类问题,您可以简单地开始禁用和启用对象,看看它们是否增加了时间。最后的测试是在粒子上进行。创建一个系统,生成足够多的粒子以影响我们的帧率,并检查剖析器。
在下面的屏幕截图中,您可以检查粒子处理功能在图表中的高亮显示,显示它需要大量时间:

图 18.28:粒子处理
然后,在脚本方面,我们还有其他需要考虑的问题,其中一些是所有编程语言和平台共有的,例如迭代长列表的对象、数据结构的误用和深度递归。然而,在本节中,我主要将讨论 Unity 特定的 API,从print或Debug.Log开始。
这个函数在控制台中获取调试信息很有用,但它也可能很昂贵,因为所有日志都会立即写入磁盘,以避免我们的游戏崩溃时丢失有价值的信息。当然,我们希望保留这些有价值的日志,但又不希望它们影响性能,我们该怎么办呢?
一种可能的方法是保留这些消息,但在最终构建中禁用非必要的消息,例如信息性消息,保持错误报告功能活跃。一种实现方式是通过编译器指令,如下面的截图所示。请记住,这种if语句是由编译器执行的,如果条件不满足,可以在编译时排除整个代码段:

图 18.29:禁用代码
在前面的截图中,你可以看到我们正在询问这段代码是否由编辑器编译,或者是为了开发构建而编译,这是一种特殊的构建,旨在用于测试(更多内容将在下一章中介绍)。你还可以使用带有编译器指令的函数创建自己的日志系统,这样你就不需要在想要排除的每个日志中都使用它们。
在本节中,我们了解了 CPU 在处理视频游戏时面临的任务,如何分析它们以查看哪些是不必要的,以及如何减少这些过程的影响。还有一些其他脚本方面会影响性能,不仅是在处理方面,也在内存方面,所以让我们在下一节中讨论它们。
优化内存
我们讨论了如何分析并优化两块硬件——CPU 和 GPU,但还有另一块硬件在我们的游戏中扮演着关键角色——RAM。这是我们放置所有游戏数据的地方。游戏可能是内存密集型应用程序,并且与许多其他应用程序不同,它们不断执行代码,因此我们需要特别注意这一点。
在本节中,我们将检查以下内存优化概念:
-
内存分配和垃圾回收
-
使用内存分析器
让我们开始讨论内存分配是如何工作的,以及垃圾回收在这里扮演什么角色。
内存分配和垃圾回收
每次我们实例化一个对象时,我们都在 RAM 中分配内存,在游戏中,我们将不断分配内存。在其他编程语言中,除了分配内存外,你还需要手动释放它,但 C#有一个垃圾回收器,这是一个跟踪未使用内存并清理它的系统。这个系统与引用计数器一起工作,它跟踪一个对象存在的引用数量,当这个计数器达到0时,这意味着所有引用都已变为 null,对象可以被释放。这个释放过程可以在几种情况下触发,最常见的情况是我们达到分配的最大内存量,并想要分配一个新的对象。在这种情况下,我们可以释放足够的内存来分配我们的对象,如果这不可能,内存就会被扩展。
在任何游戏中,你可能会不断分配和释放内存,这可能导致内存碎片化,意味着在活动对象内存块之间存在小空间,这些空间大部分是无用的,因为它们不够大以分配一个对象,或者也许这些空间的总和足够大,但我们需要连续的内存空间来分配我们的对象。在下面的图中,你可以看到一个经典的例子,即尝试将一大块内存放入由碎片化产生的细小缝隙中:

图 18.30:尝试在碎片化内存空间中实例化一个对象
一些垃圾回收系统类型,例如常规 C#中的系统,是按代进行的,这意味着内存根据其“年龄”被分成代存储桶。较新的内存将被放置在第一个桶中,这种内存往往被频繁地分配和释放。因为这个桶很小,所以在其中工作很快。第二个桶包含在第一个桶中经过先前释放扫描过程的内存。这些内存被移动到第二个桶中,以防止在经过这个过程后不断检查它是否存活,并且这种内存可能持续我们程序的生命周期。第三个桶只是第二个桶的另一层。其想法是,大多数时候,分配和释放系统将在第一个桶中工作,并且由于它足够小,连续分配、释放和压缩内存很快。
这里的问题是 Unity 使用它自己的垃圾回收系统版本,而这个版本是非代际和非压缩的,这意味着内存不会被分成桶,内存也不会被移动来填补空隙。这表明在 Unity 中分配和释放内存仍然会导致碎片化问题,如果你不调节你的内存分配,你可能会频繁执行昂贵的垃圾回收系统,导致我们的游戏出现卡顿,这在Profiler CPU Usage模块中可以以浅黄色显示。
处理这个问题的一种方法是在尽可能的情况下防止内存分配,在不必要的时候避免它。这里有一些小调整可以防止内存分配,但在查看这些调整之前,再次强调,在开始修复可能不是问题的东西之前,首先获取关于问题的数据是很重要的。这条建议适用于任何类型的优化过程。在这里,我们仍然可以使用 CPU Usage 分析器来查看 CPU 在每一帧中执行每个函数调用分配了多少内存,这很简单,只需查看 GC Alloc 列,它表示函数分配的内存量:

图 18.31:Sight 更新事件函数的内存分配
在前面的截图中,我们可以看到我们的函数分配了过多的内存,这是由于场景中有许多敌人造成的。但这并不是借口;我们每帧都在分配这么多 RAM,因此我们需要改进这一点。有几件事情可能导致我们的内存被分配,让我们先讨论基本的一些,从返回数组的函数开始。
如果我们回顾 Sight 脚本代码,我们可以看到我们分配内存的唯一时刻是在调用 Physics.OverlapSphere,这是显而易见的,因为它是一个返回数组的函数,这是一个返回可变数量数据的函数。为了做到这一点,它需要分配一个数组并将该数组返回给我们。这需要在创建函数的旁边进行,即 Unity,但在这个案例中,Unity 给我们提供了两个版本的函数——我们正在使用的版本和 NonAlloc 版本。通常建议使用第二个版本,但 Unity 使用另一个版本来简化初学者的编码。
NonAlloc 版本如下截图所示:

图 18.32:Sight 更新事件函数的内存分配
这个版本要求我们分配一个数组,以保存OverlapSphere变量可以找到的最大数量的碰撞器,并将其作为第三个参数传递。这允许我们只分配一次数组,并在需要时重复使用它。在先前的截图中,你可以看到数组是静态的,这意味着它在所有Sight变量之间共享,因为它们不会并行执行(没有Update函数)。这将正常工作。请注意,该函数将返回检测到的对象数量,所以我们只需迭代这个计数。数组可以存储之前的结果。
现在,检查你的 Profiler,注意内存分配量已经大大减少。在我们的函数中可能还有一些剩余的内存分配,但有时没有方法可以将其保持在0。然而,你可以尝试使用深度分析或通过注释一些代码来查看哪些注释可以消除分配。我挑战你尝试这样做。此外,OverlapSphere不是唯一可能发生这种情况的情况。你还有其他情况,例如GetComponents函数家族,与GetComponent不同,它找到给定类型的所有组件,而不仅仅是第一个组件,所以请注意 Unity 中任何返回数组的函数,并尝试用非分配版本替换它,如果有的话。
另一个常见的内存分配来源是字符串连接。记住,字符串是不可变的,这意味着如果你连接两个字符串,它们不会改变。第三个需要生成足够的空间来容纳前两个字符串。如果你需要多次连接,考虑使用string.Format,如果你只是在一个模板字符串中替换占位符,例如在消息中放入玩家的名字和得分,或者使用StringBuilder,这是一个只将所有要连接的字符串放在列表中的类,在需要时,它会将它们连接在一起,而不是像+运算符那样逐个连接。此外,考虑使用 C#的新字符串插值功能。你可以在以下截图中看到一些示例:

图 18.33:C#中的字符串管理
最后,一个值得考虑的经典技术是对象池,它适用于需要不断实例化和销毁对象的情况,例如子弹或效果。在这种情况下,使用常规的Instantiate和Destroy函数会导致内存碎片化,但对象池通过分配尽可能多的所需对象来解决这个问题。它通过从预分配的函数中取一个来替换Instantiate,并通过将对象返回到池中来替换Destroy。
以下截图显示了简单的池:

图 18.34:简单的对象池
有几种方法可以改进这个池,但现在它已经足够好了。请注意,当对象从池中取出时,需要重新初始化它们,你可以通过 OnEnable 事件函数或创建一个自定义函数来通知对象这样做。此外,请注意,Unity 最近添加了一个 Object Pool 类,你可以在以下链接中调查:docs.unity3d.com/2022.1/Documentation/ScriptReference/Pool.ObjectPool_1.html,但我仍然建议先自己制作,以掌握池的概念。
现在我们已经探索了一些基本的内存分配减少技术,让我们看看最新版 Unity 中引入的新 内存分析器 工具,以更详细地探索内存。
使用内存分析器
使用这个分析器,我们可以按帧检测分配的内存,但它不会显示到目前为止分配的总内存,这对于研究我们如何使用内存会有所帮助。这就是 内存分析器 可以帮助我们的地方。这个相对较新的 Unity 包允许我们在原生和托管方面对每个分配的对象进行内存快照——原生意味着内部 C++ Unity 代码,托管意味着属于 C# 方面的任何内容(即,我们的代码和 Unity 的 C# 引擎代码)。我们可以使用可视化工具探索快照,并快速看到哪种类型的对象消耗了最多的 RAM 以及它们是如何被其他对象引用的。
要开始使用 内存分析器,请执行以下操作:
- 打开 包管理器 (窗口 | 包管理器)并启用预览包(轮形图标 | 项目设置 | 启用预发布包):

图 18.35:启用预览包
- 点击 + 按钮,选择 按名称添加包…:
![img/B18585_18_36.png]
图 18.36:从 Git URLs 安装包
- 在对话框中,输入
com.unity.memoryprofiler并点击 添加。我们需要以这种方式添加包,因为它仍然是一个实验性的包:
![img/B18585_18_37.png]
图 18.37:安装内存分析器
-
安装完成后,在 窗口 | 分析 | 内存分析器 中打开 内存分析器。
-
玩游戏并点击 内存分析器 窗口中的 捕获 按钮:
![img/B18585_18_38.png]
图 18.38:捕获快照
- 点击列表中出现的快照(位于 会话 1 标签下方)以查看在捕获快照时的内存消耗摘要:
![img/B18585_18_39.png]
图 18.39:内存摘要
-
在我们的案例中,我们可以看到我们正在消耗 4.79 GB 的内存,这些内存被分配在托管堆(C#代码变量)、其他原生内存(Unity 的 C++内存)、图形与图形驱动程序、音频以及更多。这些类别中包含了不同的事物,但就目前而言,我们做得很好。在包管理器中打开包文档以获取更多关于它们的信息。
-
点击内存分析器窗口中间部分顶部的树图按钮。这将打开树视图,允许您直观地看到哪些类型的资源在内存方面要求更高:

图 18.40:内存树视图
-
在我们的案例中,我们可以看到
RenderTexture消耗了最多的内存,这属于场景中显示的图像以及一些用于后期处理效果的纹理。尝试禁用PPVolume对象并再次截图以检测差异。 -
在我的案例中,这减少了 130 MB。还有其他纹理用于其他效果,例如 HDR。如果您想探索剩余的 MB 来自哪里,请单击RenderTexture块将其细分为其对象,并根据纹理的名称进行自己的猜测:

图 18.41:内存块详细视图
- 您可以在
Texture2D块类型中重复相同的操作,这属于我们模型材料中使用的纹理。您可以查看最大的一个并检测其使用情况——可能是一个从未被足够接近地看到以证明其大小的纹理。然后,我们可以使用纹理最大尺寸导入设置来减小其大小。
就像任何分析器一样,始终在构建中直接执行分析非常有用(关于这一点将在下一章中详细介绍),因为在编辑器中捕获快照将捕获大量编辑器使用的内存,这些内存在构建中不会被使用。一个例子是加载不必要的纹理,因为编辑器可能是在您点击它们以在检查器窗口中查看预览时加载它们的。
请注意,由于内存分析器是一个包,其 UI 可能会经常变化,但其基本理念将保持不变。您可以使用此工具检测您是否以意外的方式使用内存。这里值得考虑的一个有用因素是 Unity 在加载场景时如何加载资源,这包括在加载时加载场景中引用的所有资源。这意味着您可以有,例如,一个预制体数组,这些预制体引用了具有引用纹理的材料,即使您没有实例化它们的单个实例,预制体也必须在内存中加载,从而占用空间。在这种情况下,我建议您探索使用Addressables,它提供了一种动态加载资源的方法。但现在让我们保持简单。
摘要
优化游戏并非易事,尤其是如果你不熟悉每个 Unity 系统的工作原理。遗憾的是,这是一个巨大的任务,没有人知道每个系统及其最细微细节的每一个方面,但通过本章学到的工具,我们有一种方法来探索变化如何通过探索影响系统。我们学习了如何分析 CPU、GPU 和 RAM,以及任何游戏中关键硬件是什么,还介绍了一些常见的良好实践,以避免滥用它们。
现在,你能够诊断游戏中的性能问题,收集关于 CPU、GPU 和 RAM 这三件主要硬件性能的数据,然后使用这些数据来集中优化努力,应用正确的优化技术。性能很重要,因为你的游戏需要流畅运行,以给用户带来愉快的体验。
在下一章中,我们将了解如何创建一个无需安装 Unity 的游戏构建版本,以便与他人分享。这对于性能分析也非常有用,因为性能分析构建版本将比在编辑器中进行性能分析提供更准确的数据。
加入我们的 Discord 社区!
与其他用户、Unity 游戏开发专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过 Ask Me Anything(AMA)环节与作者聊天,等等。
扫描二维码或访问链接加入社区。

第十九章:生成和调试可执行文件
因此,我们已经达到了一个点,游戏的状态足够好,可以与真实的人进行测试。问题是,我们不能假装人们会安装 Unity,打开一个项目,然后点击Play。他们想要接收一个双击即可直接播放的漂亮的可执行文件。在本章中,我们将讨论如何将我们的项目转换为易于共享的可执行文件格式,在第一部分中我们将看到如何做到这一点,然后在第二部分中,我们将看到如何将第十八章中学习的性能分析和技术调试技术应用于构建。阅读本章后,您将能够检测潜在的性能瓶颈以及如何解决最常见的瓶颈,从而提高您游戏帧率。
在本章中,我们将探讨以下构建概念:
-
构建项目
-
调试构建
让我们先看看如何构建项目以获得可共享的可执行文件。
构建项目
在软件开发(包括视频游戏)中,将我们项目的源文件转换为可执行格式的过程称为构建。生成的可执行文件针对项目配置进行了优化,以实现可能的最大性能。由于项目的不断变化,在编辑游戏时无法判断性能。在编辑游戏的同时准备最终形式的资源将非常耗时。
此外,生成的文件格式难以阅读。它们不会直接为用户展示纹理、音频和源代码文件。它们将以自定义文件结构进行格式化,因此从某种意义上说,它们被保护免受用户窃取。
实际上,有几个工具可以从视频游戏中提取源文件,尤其是从像 Unity 这样广泛使用的引擎中提取。您可以提取如纹理和 3D 模型等资源,甚至有程序可以直接从 VRAM 中提取这些资源,所以我们无法保证这些资源不会被用于游戏之外。最终,用户会在他们的磁盘上拥有这些资源的资料。
当您针对桌面平台(如 PC、Mac 或 Linux)进行构建时,构建过程相当简单,但在构建之前,我们需要注意一些设置。我们将首先看到的第一项配置是场景列表。我们已经讨论过这个问题,但这是一个很好的时刻来记住,将此列表的第一个元素设置为将首先加载的场景是很重要的。记住,您可以通过转到文件 | 构建设置并将您希望作为启动场景的所需场景拖到列表顶部来完成此操作。在我们的例子中,我们将游戏场景定义为第一个场景,但在实际游戏中,使用 UI 和一些图形创建一个主菜单场景将是理想的:

图 19.1:构建列表中的场景顺序
您还可以在此更改的另一个设置是目标平台,即构建将为创建的目标操作系统。通常,这设置为与您正在开发的相同操作系统,但如果您,例如,在 Mac 上开发,并希望为 Windows 构建,只需将目标平台设置更改为Windows。这样,结果将是一个.exe文件(Windows 可执行文件)而不是.app文件(Mac 可执行文件)。您可能还会看到 Android 和 iOS 作为其他目标平台,但制作移动游戏需要考虑其他因素,这些因素我们不会在本章中讨论:

图 19.2:目标平台
在同一窗口中,您可以点击左下角的玩家设置按钮,或者直接打开编辑 | 项目设置窗口,然后点击玩家类别以访问其余的构建设置。Unity 将生成的可执行文件称为Player。在这里,我们有一系列配置,这些配置将影响构建或玩家的行为,以下是基本配置列表:
-
公司名称:这是开发游戏的公司的名称,Unity 将使用它来创建某些文件路径,并将包含在可执行文件信息中。
-
产品名称:这是窗口标题栏和可执行文件中游戏的名称。
-
默认图标:在这里,您可以选择一个纹理作为可执行文件的图标。
-
默认光标:您可以设置一个纹理来替换常规系统光标。如果您这样做,请记住将光标热点属性设置为图像中您想要光标点击的像素。
-
分辨率和展示设置:这些设置决定了我们的游戏分辨率如何处理。
-
分辨率和展示设置|全屏模式:您可以选择游戏是启动为窗口模式还是不同的全屏模式。如果需要,您可以通过脚本稍后更改此设置。
-
分辨率和展示设置| 默认为原生分辨率:当此选项被选中且全屏模式设置为使用任何全屏选项时,Unity 将使用系统当前使用的分辨率。您可以取消选中此选项并设置您想要的分辨率。
-
启动画面:这些是游戏首次加载后显示的启动画面的设置。
-
启动画面|显示启动画面:这将启用 Unity 启动画面,显示标志作为游戏的介绍。如果您拥有 Unity Plus 或 Pro 许可证,您可以取消选中此选项以创建您自己的自定义启动画面,如果您想的话。
-
启动画面|标志列表:在这里,您可以添加一组 Unity 在启动游戏时将显示的图像。如果您使用的是 Unity 的免费版本,您被迫在这个列表中显示 Unity 标志。
-
启动画面 | 绘制模式:您可以将其设置为全部顺序以显示每个标志,一个接一个,或者设置为Unity 标志在下方以显示您的自定义介绍标志,Unity 标志始终位于您的标志下方:

图 19.3:玩家设置
在根据您的意愿配置这些设置后,下一步是进行实际的构建,这可以通过在文件 | 构建设置窗口中点击构建按钮来完成。这将要求您设置构建文件要创建的位置。我建议您在桌面上创建一个空文件夹,以便轻松访问结果。请耐心等待——这个过程可能需要一段时间,具体取决于项目的大小:

图 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
在这些路径中,您必须将 CompanyName 和 ProductName 更改为我们在之前设置的 Player 设置中属性的值,这些值被称为相同的,公司名称和产品名称。在 Windows 中,您必须将 username 替换为您在执行游戏时使用的 Windows 账户名称。请注意,文件夹可能被隐藏,因此请启用操作系统中的显示隐藏文件选项。在该文件夹内部,您将找到一个名为 Player 的文件;您可以使用任何文本编辑器打开它并查看消息。
除了从 Asset Store 下载任何自定义包之外,还有一种方法可以直接在游戏中查看控制台消息,至少是错误消息:通过创建开发版本。这是一个特殊的版本,它允许扩展调试和性能分析功能,但与最终构建相比,它不会完全优化代码,但这对一般调试来说已经足够了。
您可以通过在文件|构建设置窗口中勾选开发构建复选框来创建此类构建:

图 19.7:开发构建复选框
请记住,这里只显示错误消息,所以您可以做的一个小技巧是将 print 和 Debug.Log 函数调用替换为 Debug.LogError,这也会在控制台中打印消息,但带有红色图标。请注意,使用 Debug.LogError 来显示非错误消息不是一种好习惯,因此请限制此类消息的临时调试使用。对于永久记录,请使用日志文件或在 Asset Store 中查找运行时的自定义调试控制台。

图 19.8:调试错误消息
关于 开发构建 的有趣之处在于,与常规构建不同,错误消息会直接在构建中显示,允许你正确调试你的项目。在下一张截图中,你可以看到运行时显示的错误:

图 19.9:开发构建中的错误消息
你会注意到,除了显示错误消息外,右侧还有一个 打开日志文件 按钮,允许你查看日志文件。这是一个包含有关游戏中发生的所有消息和日志的详细信息的文本文件,以定位问题。本质上,这是与编辑器中的 控制台 面板显示的相同信息。
记住,为了使 开发构建 工作,你需要再次构建游戏;幸运的是,第一次构建是最耗时的,接下来的构建会更快。这次,你只需点击 构建并运行 按钮即可在之前构建的文件夹中进行构建。
此外,你可以像我们在 第五章,C#和视觉脚本简介 中解释的那样使用常规断点。将 IDE 附加到玩家上,它将出现在目标列表中。但为了使其工作,你必须在 构建 窗口中不仅勾选 开发构建,还要勾选 脚本调试。在这里,当勾选该项时,你会看到一个额外的选项,允许你在附加调试器之前暂停整个游戏,该选项称为 等待托管调试器。如果你想要测试一开始就发生且没有足够时间附加调试器的情况,这很有用:

图 19.10:启用脚本调试
我们还有另一种查看消息的方法,但这需要分析器工作,所以让我们以此为借口也讨论一下如何分析编辑器。
分析性能
我们将使用与上一章中看到相同的工具,但这次是为了分析玩家。幸运的是,差异很小。正如我们在上一节中所做的那样,你需要以 开发 模式构建玩家,在 构建 窗口中勾选 开发构建 复选框,然后分析器应该会自动检测到它。
让我们从以下步骤开始使用构建中的分析器:
-
通过构建来玩游戏。
-
使用 Alt + Tab(在 Mac 上为 Cmd + Tab)切换到 Unity。
-
打开分析器。
-
点击名为 播放模式 的菜单,并选择包含 Player 的项目。因为我使用了 Mac,它显示为 OSXPlayer,名称将根据构建平台而变化(例如,Windows 构建将显示为 WindowsPlayer):

图 19.11:分析玩家
注意,当你点击一个帧时,游戏不会像在编辑器中那样停止。如果你想专注于特定时刻的帧,你可以点击记录按钮(红色圆圈)使分析器停止捕获数据,这样你就可以分析到目前为止捕获到的帧。
此外,你可以看到当分析器附加到玩家时,控制台也会附加,这样你就可以直接在 Unity 中看到日志。请注意,这个版本需要打开 Unity,我们无法期望测试我们游戏的朋友也拥有它。你可能需要点击控制台上出现的玩家按钮,并检查玩家日志以使其工作:

图 19.12:在附加分析器后启用玩家日志
帧调试器也被启用以与玩家一起工作。你需要点击帧调试器中的编辑器按钮,然后,你将在可能的调试目标列表中看到玩家;选择它后,按常规点击启用。请注意,绘制调用的预览将不会在游戏视图中看到,而是在构建本身中。如果你在全屏模式下运行游戏,你可能需要在 Unity 和构建之间来回切换:

图 19.13:调试我们游戏玩家的帧
你也可以以窗口模式运行游戏,将玩家设置中的全屏模式属性设置为窗口模式,并设置一个小于你的桌面分辨率的默认分辨率,以便同时看到 Unity 和玩家:

图 19.14:启用窗口模式
最后,内存分析器也支持分析玩家,正如你可能猜到的,你只需在点击窗口顶部栏上的编辑器按钮时显示的列表中选择玩家,然后点击捕获:

图 19.15:对玩家进行记忆快照
就这样。正如你所见,Unity 分析器被设计成易于与玩家集成。如果你开始从它们那里获取数据,你将看到与编辑器分析相比的差异,尤其是在内存分析器方面。
摘要
在本章中,我们学习了如何创建游戏的可执行版本,并正确配置它,这样你就可以不仅与你的朋友分享,而且可能与世界分享!我们还讨论了如何分析我们的构建;记住,这样做将比分析编辑器提供更准确的数据,这样我们就可以更好地提高我们游戏的表现。
现在我们已经完成了我们的游戏,让我们看看你的下一个项目如何轻松地成为一个 Unity 中的增强现实应用程序,探索 AR Foundation 包。
第二十章:Unity 中的增强现实
现在,新技术扩展了 Unity 的应用领域,从游戏到各种软件,如模拟、培训、应用程序等。在 Unity 的最新版本中,我们在增强现实领域看到了许多改进,这使得我们可以在现实之上添加一层虚拟性,从而增强我们设备所能感知的内容,以创建依赖于现实世界数据的游戏,例如摄像机的图像、我们的现实世界位置和当前的天气。这也可以应用于工作环境,例如查看建筑图或检查墙内的电线管道。欢迎来到本书的额外部分,我们将讨论如何使用 Unity 的 AR Foundation 包创建增强现实(AR)应用程序。
在本章中,我们将检查以下 AR Foundation 概念:
-
使用 AR Foundation
-
为移动设备构建
-
创建一个简单的 AR 游戏
到本章结束时,你将能够使用 AR Foundation 创建 AR 应用程序,并将拥有一个使用其框架的完整功能游戏,以便你可以测试框架的功能。
让我们先探索 AR Foundation 框架。
使用 AR Foundation
当谈到 AR 时,Unity 有两个主要的工具来创建应用程序:Vuforia 和 AR Foundation。Vuforia 是一个可以在几乎所有移动设备上工作的 AR 框架,它包含了基本 AR 应用所需的所有功能,但通过付费订阅,我们可以获得更多高级功能。另一方面,完全免费的 AR Foundation 框架支持我们设备上最新的 AR 原生功能,但仅支持较新的设备。选择其中一个还是另一个很大程度上取决于你将要构建的项目类型和目标受众。然而,由于本书旨在讨论最新的 Unity 功能,我们将探讨如何使用 AR Foundation 创建我们的第一个 AR 应用,用于检测现实世界中图像和表面的位置。因此,我们将从探索其 API 开始。
在本节中,我们将检查以下 AR Foundation 概念:
-
创建 AR 基础项目
-
使用跟踪功能
让我们先讨论如何准备我们的项目,以便它能够运行 AR Foundation 应用程序。
创建 AR 基础项目
在创建 AR 项目时需要考虑的是,我们不仅会改变我们编写游戏代码的方式,还会改变游戏设计方面。AR 应用程序有差异,尤其是在用户交互方式上,以及一些限制,例如用户始终控制着摄像头。我们不能简单地移植现有的游戏到 AR 而不改变游戏的核心体验。这就是为什么在本章中,我们将着手一个全新的项目;改变我们迄今为止创建的游戏以使其在 AR 中运行将非常困难。
在我们的案例中,我们将创建一个游戏,用户控制一个移动“标记”的玩家,这个“标记”是一个可以打印的物理图像,它将允许我们的应用程序识别玩家在现实世界中的位置。我们可以在移动该图像的同时移动玩家,这个虚拟玩家将自动射击最近的敌人。这些敌人将从用户需要在家庭不同部分放置的特定生成点出现。例如,我们可以在墙上放置两个生成点,并将我们的玩家标记放在房间中间的桌子上,这样敌人就会朝它们走去。在下面的图像中,你可以看到游戏的预览:

图 20.1:完成的游戏。圆柱体是敌人生成器,胶囊是敌人,立方体是玩家。这些在手机显示的标记图像中定位
我们将以创建第一个游戏相同的方式开始创建一个新的基于 URP 的项目。需要考虑的是,AR Foundation 与其他管道一起工作,包括内置的,以防你希望在现有项目中使用它。如果你不记得如何创建项目,请参阅第一章,创建 Unity 项目。
一旦你进入你的新空白项目,就像我们之前安装其他包一样,从包管理器安装 AR Foundation 包——即,从窗口 | 包管理器。记得设置包管理器,使其显示所有包,而不仅仅是项目中的包(窗口左上角的包按钮需要设置为Unity 注册表)以及预览版本(点击轮形图标,然后项目设置,在出现的窗口上检查启用预发布包)。
在撰写本书时,最新的稳定版本是 4.2.3,但我们将探索 5.0.0 预览版 13。记得通过点击左侧的三角形按钮打开包版本列表,以查看预览版本。如果你发现比我更新的版本,你可以尝试使用那个版本,但像往常一样,如果某些东西的工作方式与我们想要的不同,请安装 5.0.0-pre.13。像往常一样,如果出现提示你启用新输入系统的警告,请点击是:

图 20.2:安装 AR Foundation
在我们安装任何其他需要的包之前,现在是讨论 AR Foundation 框架核心思想的好时机。这个包本身并不做任何事情;它定义了一系列移动设备提供的 AR 功能,例如图像跟踪、云点和对象跟踪,但实际如何实现这些功能的代码包含在 Provider 包中,例如 Apple ARKit XR 插件 和 Google ARCore XR 插件 包。这样设计是因为,根据您想要与之合作的目标设备,实现这些功能的方式会有所不同。例如,在 iOS 中,Unity 使用 AR Kit 实现这些功能,而在 Android 中,它使用 AR Core;它们是平台特定的框架。请记住,安装与 AR Foundation 相同版本的这些平台包(在本例中为 5.0.0 预览版 13)。
在这里需要考虑的是,并非所有 iOS 或 Android 设备都支持 AR Foundation 应用。您在网上搜索支持 AR Core 和 AR Kit 的设备时,可能会找到一个更新的支持设备列表。在撰写本文时,以下链接提供了支持设备列表:
-
iOS:
www.apple.com/lae/augmented-reality(页面底部) -
Android:
developers.google.com/ar/devices
此外,目前还没有 PC Provider 包,因此迄今为止测试 AR Foundation 应用程序的唯一方法是直接在设备上测试,但测试工具很快就会发布。在我的情况下,我将为 iOS 创建一个应用程序,因此除了 AR Foundation 包之外,我还需要安装 ARKit XR 插件。
然而,如果您想为 Android 开发,请安装 ARCore XR 插件(或者如果您针对两个平台,则两者都安装)。此外,我将使用这些包的 4.1.7 版本。通常,AR Foundation 和 Provider 包的版本是一致的,但应用相同的逻辑,就像您选择 AR Foundation 版本时一样。在下面的屏幕截图中,您可以在 包管理器 中看到 ARKit 包:
现在我们有了所需的插件,我们需要为 AR 准备一个场景,如下所示:
-
在 文件 | 新建场景 中创建一个新的场景,并选择 基本 (URP) 模板。
-
删除 主摄像头;我们将使用另一个摄像头。
-
在 GameObject | XR 菜单中,创建一个 AR Session GameObject。
-
在相同的菜单中,创建一个包含 Camera 的 XR Origin (Mobile AR) 对象。
-
在 XR Origin 中选择 主摄像头。
-
将 AR 摄像机管理器 组件的 渲染模式 属性设置为 在不透明物体之后。这是针对当前版本中另一种模式下无法正确渲染摄像头的错误的一个解决方案。
-
您的层次结构应该如下所示:
![]()
图 20.3:入门 AR 场景
AR Session对象将负责初始化 AR 框架,并处理 AR 系统的所有更新逻辑。XR Origin对象将允许框架定位跟踪对象,如图像和点云,相对于场景的相对位置。设备会告知跟踪对象相对于设备认为的“原点”的位置。这通常是当应用程序开始检测对象时你指向的第一个区域,因此XR Origin对象将代表你物理空间中的那个点。最后,你可以检查原点内的相机,它包含一些额外的组件,其中最重要的是Tracked Pose Driver,它将使你的Camera对象随着设备移动。由于设备的位置相对于会话原点对象的点,相机需要位于原点对象内。
如果你正在处理一个 URP 项目(我们的情况),则需要额外的一步,即设置渲染管线,使其支持在应用程序中渲染相机图像。为此,前往我们创建项目时生成的Settings文件夹,查找URP-HighFidelity-Renderer文件,并选择它。在Renderer Features列表中,点击Add Renderer Feature按钮,并选择AR Background Renderer Feature。在下面的屏幕截图中,你可以看到 Forward Renderer 资产应该看起来是什么样子:

图 20.4:为 URP 添加支持
就这样!我们准备好开始探索 AR Foundation 组件,以便我们可以实现跟踪功能。
使用跟踪功能
对于我们的项目,我们需要 AR 中最常见的两个跟踪功能(但不是唯一的功能):图像识别和平面检测。第一个功能包括检测特定图像在现实世界中的位置,以便我们可以在其上方放置数字对象,例如玩家。第二个功能,平面检测,包括识别现实生活中的表面,如地板、桌子和墙壁,以便我们有放置对象(如敌人的出生点)的参考。只有水平和垂直表面被识别(某些设备上仅识别垂直表面)。
我们需要做的第一件事是告诉我们的应用程序它需要检测哪些图片,如下所示:
- 将图片添加到项目中,使其可以打印或显示在移动设备上。在现实世界中展示图片的方式对于测试这是必要的。在这种情况下,我将使用以下图片:

图 20.5:要跟踪的图片
尽量获取包含尽可能多特征的图片。这意味着一个有很多小细节的图片,比如对比度、尖锐的角落等。这些都是我们的 AR 系统用来检测它的;细节越多,识别越好。如果你的设备在检测我们当前的图片时遇到困难,请尝试其他图片(经典的 QR 码可能有所帮助)。
-
考虑到某些设备可能对某些图像存在困难,例如本书中建议的图像。如果在测试时产生问题,请尝试使用另一个图像。你将在本章接下来的部分中在自己的设备上测试此图像,所以请记住这一点。
-
通过在项目面板中点击+按钮并选择XR | 参考图像库来创建一个参考图像库,这是一个包含我们希望应用程序识别的所有图像的资产。

图 20.6:创建参考图像库
-
选择我们创建的参考图像库资产,并点击添加图像按钮以将新图像添加到库中。
-
将纹理拖动到纹理槽(标有None的那个槽)中。
-
打开指定大小,并将物理大小设置为图像在现实生活中的打印尺寸,单位为米。在这里尽量准确;在某些设备上,如果没有设置正确的值,可能会导致图像无法被跟踪:

图 20.7:添加要识别的图像
现在我们已经指定了要检测的图像,让我们通过在现实生活中的图像上方放置一个立方体来测试这一点:
-
创建一个立方体的 Prefab,并向其添加AR 跟踪图像组件。
-
请记住,由于默认的立方体将是 1 米乘 1 米,这在 AR 中会非常大,所以请在每个轴上设置一个小比例,例如 0.1。
-
将AR 跟踪图像管理器组件添加到XR 原点对象中。这将负责检测图像并在其位置创建对象。
-
将之前步骤中创建的图像库资产拖动到组件的序列化库属性中,以指定要识别的图像。
-
将立方体Prefab 拖动到组件的跟踪图像 Prefab属性中:

图 20.8:设置跟踪图像管理器
以下是所有内容!在本章的“为移动设备构建”部分中,当我们创建 iOS 或 Android 构建时,我们将看到立方体在图像在现实世界中的相同位置生成。请记住,你需要在这个设备上测试这一点,我们将在下一节中这样做,所以现在,让我们继续编写我们的测试应用程序代码:

图 20.9:位于手机显示图像顶部的立方体
让我们也准备我们的应用程序,以便它可以检测并显示相机识别出的平面表面。这很简单,只需将AR 平面管理器组件添加到XR 原点对象中。

图 20.10:添加 AR 平面管理器组件
此组件会在我们移动相机时检测我们房子上的表面平面。检测它们可能需要一段时间,因此可视化检测区域以获取有关此操作的反馈非常重要,以确保其正常工作。我们可以从 AR Plane Manager 的组件引用手动获取有关平面的信息,但幸运的是,Unity 允许我们轻松地可视化平面。让我们看看:
-
首先通过在GameObject | 3D Object | Plane中创建一个平面的预制体。
-
向其添加一个Line Renderer。这将使我们能够在检测区域的边缘绘制线条。
-
将Line Renderer的Width属性设置为较小的值,例如
0.01,将Color渐变属性设置为黑色,并取消选中Use World Space:

图 20.11:设置线渲染器
- 记得创建一个具有适当着色器(Universal Render Pipeline/Unlit)的材质,并将其设置为Materials列表属性下Line Renderer组件的材质:

图 20.12:创建线渲染器材质
- 此外,创建一个透明材质,并将其用于MeshRenderer平面。我们希望透过它,这样我们就可以轻松地看到下面的真实表面:

图 20.13:检测到的平面的材质
-
将AR Plane和AR Plane Mesh Visualizer组件添加到Plane预制体中。
-
将预制体拖动到XR Origin对象的AR Plane Manager组件的Plane Prefab属性中:

图 20.14:设置平面可视化预制体
现在,我们有了查看平面的方法,但看到它们并不是我们能做的唯一事情(有时,我们甚至不希望它们可见)。平面的真正力量在于在现实生活中的表面上放置虚拟对象,点击特定的平面区域,并获取其实际位置。我们可以通过AR Plane Manager或访问我们的可视化平面的AR Plane组件来访问平面数据,但更简单的方法是使用AR Raycast Manager组件。
AR Raycast Manager组件为我们提供了与 Unity 物理系统中的Physics.Raycast函数等效的功能,你可能还记得,这个函数用于创建从某个位置开始并指向指定方向的虚拟射线,以便它们击中表面并检测确切的击中点。AR Raycast Manager提供的版本,而不是与物理碰撞体碰撞,而是与跟踪对象碰撞,主要是点云(我们未使用它们)和我们要跟踪的“平面”。我们可以通过以下步骤测试这个功能:
-
将AR Raycast Manager组件添加到XR Origin对象中。
-
在XR Origin对象中创建一个名为
SpawnerPlacer的自定义脚本。 -
在Awake缓存中添加对
ARRaycastManager的引用。你需要将using UnityEngine.XR.ARFoundation;行添加到脚本顶部,以便在这个类中可以使用我们的脚本。 -
创建一个
List<ARRaycastHit>类型的私有字段并实例化它;Raycast 函数将检测我们的射线击中的每个平面,而不仅仅是第一个:


-
在Update下检查触摸屏是否被按下(
Touchscreen.current.primaryTouch.press.isPressed)。你需要在文件顶部使用using UnityEngine.InputSystem;来使用新的输入系统。 -
在上一步的
if语句内部,为调用AR Raycast Manager的Raycast函数添加另一个条件,将触摸位置作为第一个参数,将碰撞列表作为第二个参数传递(Touchscreen.current.primaryTouch.position.ReadValue())。 -
这将向玩家触摸屏幕的方向发射射线,并将碰撞存储在我们提供的列表中。如果击中某个物体,则返回
true,如果没有击中,则返回false。 -
添加一个公共字段来指定在触摸位置实例化的 Prefab。你可以创建一个球体 Prefab 并将其分配给此字段进行测试;这里不需要为 Prefab 添加任何特殊组件。请记住设置一个小的缩放。
-
在列表中存储的第一个碰撞的Pose属性的Position和Rotation字段中实例化 Prefab。由于碰撞按距离排序,所以第一个碰撞是最接近的。你的最终脚本应该如下所示:

图 20.16:射线投射组件
在本节中,我们学习了如何使用 AR Foundation 创建新的 AR 项目。我们讨论了如何安装和设置框架,以及如何检测现实生活中的图像位置和表面,然后是如何在它们上方放置对象。
如你所注意到的,我们从未点击Play来测试这个,遗憾的是,在撰写本书时,我们无法在编辑器中测试这个。相反,我们需要直接在设备上测试这个。因此,在下一节中,我们将学习如何为 Android 和 iOS 等移动设备进行构建。
为移动设备构建
Unity 是一个非常强大的工具,它能够轻松解决游戏开发中最常见的问题,其中之一就是为多个目标平台构建游戏。现在,为这些设备构建我们的项目部分在 Unity 中很容易完成,但每个设备在安装开发版本时都有其与 Unity 无关的细微差别。为了测试我们的 AR 应用程序,我们需要直接在设备上测试它。因此,让我们探讨如何使我们的应用程序在 Android 和 iOS(最常见的移动平台)上运行。
在深入探讨这个主题之前,值得提到的是,以下程序会随着时间的推移而大量变化,因此您需要在互联网上找到最新的说明。Unity Learn 门户网站(learn.unity.com/tutorial/how-to-publish-to-android-2)可能是本书记载的说明失败时的一个良好替代方案,但请先尝试这里的步骤。
在本节中,我们将探讨以下移动构建概念:
-
Android 构建
-
iOS 构建
让我们先讨论如何构建我们的应用程序,使其在 Android 手机上运行。
Android 构建
与其他平台相比,创建 Android 构建相对容易,因此我们将从 Android 开始。请记住,您需要一个能够运行 AR Foundation 应用程序的 Android 设备,因此请参阅本章使用 AR Foundation部分中提到的关于支持 Android 的设备的链接。我们需要做的第一件事是检查我们是否已安装 Unity 的 Android 支持,并配置我们的项目以使用该平台。为此,请按照以下步骤操作:
-
关闭 Unity 并打开Unity Hub。
-
前往安装部分,找到您正在工作的 Unity 版本。
-
在您使用的 Unity 版本右上角点击轮形图标按钮,然后点击添加模块:

图 20.17:向 Unity 版本添加模块
- 确保已勾选Android 构建支持以及点击其左侧箭头时显示的子选项。如果没有,请勾选它们,然后点击窗口右下角的继续按钮来安装它们:

图 20.18:将 Android 支持添加到 Unity
-
通过勾选接受条款复选框并点击继续按钮,接受所有条款和条件提示。
-
打开本章中创建的 AR 项目。
-
前往构建设置(文件 | 构建设置)。
-
从列表中选择Android平台,然后点击窗口右下角的切换平台按钮:

图 20.19:切换到 Android 构建
要在 Android 上构建应用程序,我们需要满足一些要求,例如安装 Java SDK(不是常规的 Java 运行时)和 Android SDK,但幸运的是,Unity 的新版本会处理这些。为了确保我们已安装所需的依赖项,请按照以下步骤操作:
-
前往 Unity 预设 (在 Windows 上为 编辑 | 预设,在 Mac 上为 Unity | 预设)。
-
点击 外部工具。
-
确认 Android 部分所有标有 …随 Unity 安装 的选项都已勾选。这意味着我们将使用 Unity 安装的全部依赖项:

图 20.20:使用已安装的依赖项
在 developers.google.com/ar/develop/unity-arf/quickstart-android 可以找到一些额外的 Android ARCore 特定相关设置,您可以通过以下步骤应用它们:
-
前往 玩家设置 (编辑 | 项目设置 | 玩家)。
-
从 其他设置 部分取消选择 多线程渲染 和 自动图形 API。
-
如果存在,从 图形 API 列表中移除 Vulkan。
-
将 最小 API 级别 设置为 Android 7.0:

图 20.21:AR Core 设置
-
将脚本后端设置为 IL2CPP。
-
选择 ARM64 复选框以支持 Android 64 位设备。
-
选择 覆盖默认包标识符 并设置一些自定义的,例如
com.MyCompany.MyARApp。 -
前往 编辑 | 项目设置 并选择 XR 插件管理 选项。
-
在 插件提供者 下选择 Google ARCore 以确保它将在我们的构建中启用;如果不这样做,我们不会看到任何内容:

图 20.22:ARCore 插件已启用
现在,您可以从 文件 | 构建设置(通过使用 构建 按钮)像往常一样构建应用程序。这次,输出将是一个单独的 APK 文件,您可以通过将文件复制到您的设备并打开它来安装。请记住,为了安装未从 Play 商店下载的 APK,您需要将设备设置为允许 安装未知应用。该选项的位置因 Android 版本和您所使用的设备而异,但该选项通常位于 安全 设置中。一些 Android 版本在安装 APK 时会提示您查看这些设置。
现在,每次我们想要创建构建时,我们都可以复制并安装生成的 APK 构建文件。然而,我们可以让 Unity 为我们完成这项工作,使用构建和运行按钮。构建应用程序后,此选项将查找通过 USB 连接到您的电脑的第一个 Android 设备,并自动安装应用程序。为了使此功能正常工作,我们需要准备我们的设备和电脑,如下所示:
- 在您的设备上,在设置部分找到构建号,其位置,再次提醒,可能会根据设备而变化。在我的设备上,它位于关于手机 | 软件信息部分:
![img/B18585_20_23.png]
图 20.23:定位构建号
-
轻轻点击几次,直到设备告诉您您现在是一名程序员。此过程启用了设备中的隐藏开发者选项,您现在可以在设置中找到它。
-
打开开发者选项并开启USB 调试,这允许您的电脑在您的设备上拥有特殊权限。在这种情况下,它允许您安装应用程序。
-
如果使用 Windows,请从您的手机制造商的网站上安装 USB 驱动程序。例如,如果您有一部三星设备,请搜索
Samsung USB Driver。另外,如果您找不到它,您可以查找Android USB Driver以获取通用驱动程序,但如果您的设备制造商有自己的驱动程序,这可能不起作用。在 Mac 上,此步骤通常不是必需的。 -
将您的设备(如果尚未连接,请重新连接)。您的电脑将出现允许 USB 调试的选项。请选择始终允许并点击确定:

图 20.24:允许 USB 调试
-
接受出现的允许数据提示。
-
如果这些选项没有出现,请检查您的设备USB 模式是否设置为调试,而不是其他任何模式。
-
在 Unity 中,使用构建和运行按钮进行构建,并将
apk文件保存到文件夹中。请耐心等待,因为第一次可能需要一些时间。
请记住,如果您在检测我们实例化玩家(以我的情况为例,是 Unity 标志)所在的位置时遇到问题,请尝试另一个镜像。这可能会根据您的设备功能有很大的不同。
就这样!现在您的应用程序已经在设备上运行,让我们学习如何为 iOS 平台做同样的事情。
为 iOS 构建
在 iOS 上开发时,您可能需要花费一些钱。您需要运行 XCode,这是一款只能在 macOS X 上运行的软件。因此,您需要一个可以运行它的设备,例如 MacBook、Mac mini 等。可能有在 PC 上运行 macOS X 的方法,但您需要自己找出并尝试。除了在 Mac 和 iOS 设备(iPhone、iPad、iPod 等)上花费之外,您还需要为 Apple 开发者账户付费,每年 99 美元,但仅当您计划发布游戏时;用于测试目的,您可以继续使用。
要创建 AR Foundation iOS 构建,您应该执行以下操作:
-
获取一台 Mac 电脑和一款 iOS 设备。
-
创建一个苹果开发者账户(在撰写本书时,您可以在
developer.apple.com/创建一个)。 -
从 App Store 将最新版本的 XCode 安装到您的 Mac 上。
-
检查您在 Unity Hub 上的 Unity 安装中是否有 iOS 构建支持。有关此步骤的更多信息,请参阅 为 Android 构建 部分。
-
在 构建设置 下切换到 iOS 平台,通过选择 iOS 并点击 切换平台 按钮:

图 20.25:切换到 iOS 构建
-
前往 编辑 | 项目设置 并选择 播放器 选项。
-
在 其他设置 中,如果尚未设置,请设置 相机使用描述 属性。这将是一个显示给用户的消息,告诉他们我们为什么需要访问他们的相机:

图 20.26:关于相机使用的消息
-
前往 编辑 | 项目设置 并选择 XR 插件管理 选项。
-
在 插件提供者 下检查 Apple ARKit 以确保它在我们的构建中启用;如果没有,我们将看不到任何内容:

图 20.27:ARKit 插件已启用
- 在 构建设置 窗口中点击 构建 按钮,创建一个构建文件夹,并等待构建完成。完成后,应打开一个包含生成文件的文件夹。
您会注意到构建过程的结果将是一个包含 XCode 项目的文件夹。Unity 无法直接创建构建,因此它生成一个您可以使用我们之前提到的 XCode 软件打开的项目。您需要遵循以下步骤使用本书中使用的 XCode 版本(13.4.1)创建构建:
- 双击生成文件夹内的
.xcodeproj文件:

图 20.28:XCode 项目文件
-
前往 XCode | 首选项。
-
在 账户 选项卡中,点击窗口左下角的 + 按钮,并使用您注册为苹果开发者的苹果账户登录:

图 20.29:账户设置
- 连接您的设备,并从窗口的左上角选择它,现在应该显示为 任何 iOS 设备。您可能需要首先解除设备的锁定,点击 信任 按钮,并等待 XCode 完成设置您的设备,以便在列表中看到您的设备:

图 20.30:选择设备
-
XCode 可能会要求您安装某些更新以支持您的设备;如果需要,请安装它们。
-
在左侧面板中,点击文件夹图标,然后点击 Unity-iPhone 设置以显示项目设置。
-
从目标列表中选择Unity-iPhone,然后点击签名与能力选项卡。
-
选中自动管理签名,并在提示中点击启用自动按钮。
-
在团队设置中,选择显示为个人团队的选项。
-
如果您看到“无法注册包标识符”错误,只需更改包标识符设置为其它的一个,始终遵守格式(
com.XXXX.XXXX),然后点击重试直到问题解决。
一旦找到一个可以工作的,将其设置在 Unity 中(在玩家设置下的包标识符)以避免在每次构建时都需要更改它:

图 20.31:设置您的 iOS 项目
-
点击窗口左上角的播放按钮,等待构建完成。在这个过程中,您可能需要输入密码几次,所以请这样做。
-
当构建完成时,请记住解锁设备。会出现一个提示要求您这样做。请注意,除非您解锁手机,否则进程不会继续。如果失败,请点击取消运行并再次尝试,这次确保设备已解锁;请记住再次在列表中选择您的设备。此外,尝试使用最新的 XCode 以支持设备上安装的最新 iOS 版本。
-
如果您看到一个获取调试符号提示,它永远不会结束,请重新启动您的设备。
-
完成后,您可能会看到一个错误,表示应用无法启动,但它已经被安装。如果您尝试打开它,它将告诉您需要信任该应用的开发者,您可以通过访问设备的设置来完成此操作。
-
从那里,转到通用 | VPN 与设备管理并选择列表中的第一个开发者。
-
点击蓝色信任…按钮,然后信任。
-
尝试再次打开应用。
-
如果您在检测我们实例化播放器(我的情况是鹅卵石图像)的图像时遇到困难,请记得尝试另一张图像。这可能会因您的设备功能而大不相同。
在本节中,我们讨论了如何构建一个可以在 iOS 和 Android 上运行的 Unity 项目,从而允许我们创建移动应用——具体来说是 AR 移动应用。像任何构建一样,我们可以遵循一些方法来分析和调试,就像我们在查看 PC 构建时看到的那样,但在这里我们不会讨论这一点。现在我们已经创建了第一个测试项目,我们将通过添加一些机制来将其转换为真正的游戏。
创建一个简单的 AR 游戏
如我们之前讨论的,想法是创建一个简单的游戏,我们可以移动我们的玩家,同时移动一个现实生活中的图像,并且只需轻触我们想要它们出现的位置,例如墙壁、地板、桌子等,就可以放入一些敌人生成器。我们的玩家将自动射击最近的敌人,而敌人将直接射击玩家,因此我们唯一的任务就是移动玩家以避免子弹。我们将使用与本书主要项目中使用的脚本非常相似的脚本来实现这些游戏机制。
在本节中,我们将开发以下 AR 游戏功能:
-
生成玩家和敌人
-
编写玩家和敌人行为代码
首先,我们将讨论如何让我们的玩家和敌人出现在应用中,具体是在现实世界的位置,然后我们将让它们移动并互相射击以创建特定的游戏机制。让我们从生成开始。
生成玩家和敌人
为了实现我们的游戏玩法,我们首先需要生成可以与之交互的对象。让我们从玩家开始,因为那是最容易处理的一个:我们将创建一个 Prefab,其中包含我们想要玩家拥有的图形(在我的情况下,只是一个立方体),一个带有是运动学复选框的 Rigidbody(玩家将会移动),以及一个AR 跟踪图像脚本。我们将该 Prefab 设置为XR 原点对象中AR 跟踪图像管理器组件的跟踪图像 Prefab。这将使玩家出现在跟踪图像上。请记住,根据现实生活中的尺寸设置玩家的尺寸。在我的情况下,我将玩家缩放到 0.05、0.05、0.05。由于原始立方体的尺寸是 1 米,这意味着我的玩家将是 5x5x5 厘米。
你的玩家Prefab 应该看起来如下:

图 20.32:起始的“玩家”Prefab
敌人需要做更多的工作,如下所示:
-
创建一个名为
Spawner的 Prefab,其中包含你想要你的生成器拥有的图形(在我的情况下是一个圆柱体)及其现实生活尺寸(小尺寸)。 -
添加一个每几秒生成一个 Prefab 的自定义脚本,例如以下截图所示。
-
你会注意到使用
Physics.IgnoreCollision来防止SpawnerGameObject 与生成的 GameObject 发生碰撞,获取两个物体的碰撞器,并将它们传递给函数。你也可以使用层碰撞矩阵来防止碰撞,就像我们在本书的主要项目中做的那样,如果你愿意的话:

图 20.33:生成器脚本
-
创建一个带有所需图形(在我的情况下是一个胶囊)和带有是运动学复选框的
Rigidbody组件的敌人Prefab。这样,敌人将会移动,但不会使用物理。请记住考虑敌人的现实生活尺寸。 -
设置 Spawner 的Prefab属性,以便它在期望的时间频率下生成我们的敌人:

图 20.34:配置 Spawner
- 在 XR Origin 对象中设置
SpawnerPlacer的 Prefab,以便它生成我们之前创建的SpawnerPrefab。
到此,第一部分就完成了。如果你现在测试游戏,你将能够点击应用中检测到的平面,并看到 Spawner 开始创建敌人。你还可以查看目标图像,并看到我们的立方体玩家出现。
现在我们已经在场景中有对象了,让我们让它们做一些更有趣的事情,从敌人开始。
编写玩家和敌人行为
为了射击玩家,敌人必须朝玩家移动,因此它需要访问玩家的位置。由于敌人是实例化的,我们无法将玩家引用拖动到 Prefab 中。然而,玩家也已经实例化了,因此我们可以向玩家添加一个使用Singleton模式(如我们在第八章赢和输的条件中所述)的PlayerManager脚本。
要做到这一点,请按照以下步骤操作:
- 创建一个类似于以下截图中的
PlayerManager脚本并将其添加到玩家中:

图 20.35:创建 PlayerManager 脚本
- 现在敌人有了玩家的引用,让我们通过添加一个
LookAtPlayer脚本,如图所示,让它们看向玩家:

图 20.36:创建 LookAtPlayer 脚本
- 此外,添加一个类似于以下截图中的简单
MoveForward脚本,使敌人不仅看向玩家,而且朝他们移动。由于LookAtPlayer脚本使敌人面向玩家,因此沿着Z轴移动的脚本就足够了:

图 20.37:创建 MoveForward 脚本
现在,我们将处理玩家移动。记住,我们的玩家是通过移动图像来控制的,因此在这里,我们实际上是在指旋转,因为玩家需要自动朝向最近的敌人看和射击。为此,请按照以下步骤操作:
-
创建一个
Enemy脚本并将其添加到EnemyPrefab 中。 -
创建一个类似于以下截图中的
EnemyManager脚本并将其添加到场景中的空EnemyManager对象中:

图 20.38:创建 EnemyManager 脚本
- 在
Enemy脚本中,确保将对象注册到EnemyManager的all列表中,就像我们在本书的主要项目中使用WavesManager时那样:

图 20.39:创建 Enemy 脚本
- 创建一个类似于以下截图中的
LookAtNearestEnemy脚本并将其添加到PlayerPrefab 中,使它看向最近的敌人:

图 20.40:看向最近的敌人
现在我们物体按预期旋转和移动,唯一缺少的是射击和造成伤害:
- 创建一个类似于以下截图所示的
Life脚本,并将其添加到 Player 和 Enemy 组件中。请记住设置生命值字段的数量。你将看到这个版本的Life,而不是需要每帧检查生命值是否达到零。我们创建了一个Damage函数来检查是否造成了伤害(Damage函数被执行),但本书项目的另一个版本也有效:

图 20.41:创建生命组件
-
创建一个具有所需图形的
Bullet预制件,将碰撞器的 Is Trigger 复选框勾选,添加一个带有 Is Kinematic 勾选的Rigidbody组件(一个运动学触发碰撞器),以及适当的真实尺寸。 -
将
MoveForward脚本添加到 Bullet 预制件中,使其能够移动。请记住设置速度。 -
将
Spawner脚本添加到 Player 和 Enemy 组件中,并将 Bullet 预制件设置为要实例化的预制件,以及所需的生成频率。 -
将类似于以下截图所示的
Damager脚本添加到 Bullet 预制件中,使子弹对其接触的物体造成伤害。请记住设置伤害值:

图 20.42:创建伤害脚本 – 第一部分
- 将类似于以下截图所示的
AutoDestroy脚本添加到 Bullet 预制件中,使其在一段时间后消失。请记住设置销毁时间:

图 20.43:创建伤害脚本 – 第二部分
和此同时!正如你所见,我们基本上使用与主游戏几乎相同的脚本创建了一个新游戏,主要是因为我们设计它们时要通用(而且游戏类型几乎相同)。当然,这个项目可以有很多改进,但我们有一个很好的基础项目来创建令人惊叹的 AR 应用。
摘要
在本章中,我们介绍了 AR Foundation Unity 框架,探讨了如何设置它,以及如何实现几个跟踪功能,以便我们可以在真实生活物体上定位虚拟物体。我们还讨论了如何构建我们的项目,使其能够在 iOS 和 Android 平台上运行,这是我们在撰写本文时测试 AR 应用的唯一方式。最后,我们基于主项目中创建的游戏创建了一个简单的 AR 游戏,但对其进行了修改,使其适合在 AR 场景中使用。
通过这些新知识,你将能够开始你的 AR 应用开发者之路,通过检测真实物体的位置,创建将虚拟物体添加到真实物体上的应用。这可以应用于游戏、培训应用和模拟。你甚至可能找到新的应用领域,所以利用这项新技术及其新可能性吧!
好的,这就是通过 Unity 2022 的旅程的终点了。我真的很高兴你在书中达到了这个阶段。我希望这些知识能帮助你利用市场上最灵活和强大的工具之一:Unity,来提升或开始你的游戏开发生涯。我希望有一天能看到你的作品!路上见!












浙公网安备 33010602011771号