Unity-游戏优化指南第三版-全-

Unity 游戏优化指南第三版(全)

原文:zh.annas-archive.org/md5/cb44d9643620ca177b548bedb99dac27

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

用户体验是任何游戏的关键组成部分。这不仅包括我们游戏的故事和玩法,还包括图形的流畅度、游戏连接到多人服务器的可靠性、对用户输入的响应性,甚至由于移动设备和云下载的普及,最终应用程序文件的大小。由于 Unity 等工具的出现,游戏开发的门槛大大降低,这些工具提供了大量有用的开发功能,同时仍然对个人开发者开放。然而,由于游戏行业的竞争激烈,玩家对我们期望提供的最终产品质量的要求每天都在提高。我们应该预料到玩家和评论家会仔细审查我们游戏的每一个方面。

性能优化的目标与用户体验紧密相连。优化不良的游戏可能导致帧率低、冻结、崩溃、输入延迟、加载时间长、运行时行为不一致和抖动、物理引擎故障,甚至过度高的电池功耗(对于移动设备来说,这是一个常被忽视的指标)。仅仅一个这些问题就可能是游戏开发者最可怕的噩梦,因为评论往往会集中在我们做得不好的那一点上,而忽略我们做得好的所有事情。

性能优化的一个目标是要充分利用可用的资源,包括 CPU 资源,如消耗的周期数、我们使用的内存空间(称为 RAM),以及图形处理单元GPU)资源,这包括其自身的内存空间(称为 VRAM)、填充率、内存带宽等。然而,性能优化的最重要的目标是确保没有任何单一资源在不适时造成瓶颈,并且最高优先级任务首先得到处理。即使是微小的、间歇性的性能波动和迟钝也可能将玩家从体验中拉出来,破坏游戏沉浸感,限制我们创造预期体验的潜力。另一个考虑因素是,我们能够节省的资源越多,我们就能在我们的游戏中实施更多的活动,从而产生更令人兴奋和动态的游戏玩法。

决定何时退一步停止进行性能提升也是至关重要的。在一个时间和资源无限的世界里,总有另一种方法可以使它变得更好、更快、更高效。在开发过程中,我们必须决定产品已经达到了可接受的质量水平。如果不这样做,我们可能会陷入反复实施变化,而这些变化带来的实际效益很小或没有,而且每次变化也都有可能引入更多的错误。

判断一个性能问题是否值得修复的最佳方式是回答这个问题:“用户会注意到吗?”如果这个问题的答案是“不会”,那么性能优化将是一种徒劳的努力。在软件开发中有一个古老的谚语:

过早的优化是万恶之源。

过早的优化是重构和重构代码以增强性能的致命罪过,没有任何证据表明这是必要的。这可能意味着在没有表明性能问题甚至存在的情况下进行更改,或者在我们没有证明一个性能问题可能来自特定区域之前,就认为它可能来自该区域。

当然,唐纳德·克努特(Donald Knuth)这个常见说法的原始版本继续说道,我们仍然应该编写我们的代码以避免更直接和明显的问题。然而,真正的性能优化工作在项目末尾可能需要花费很多时间,我们应该计划时间来适当打磨产品,同时避免在没有任何有效证据的情况下实施更昂贵和耗时的更改。这类错误已经让软件开发者作为一个整体,浪费了大量的工作时间。

本书旨在为您提供检测和修复 Unity 应用程序中性能问题的工具、知识和技能,无论这些问题源自何处。这些瓶颈可能出现在硬件组件中,如 CPU、GPU 和 RAM,或者出现在软件子系统,如物理、渲染和 Unity 引擎本身。

优化我们游戏性能将大大提高它们在每天充斥着新高质量游戏的竞争激烈的市场中成功和脱颖而出的机会。

本书面向的对象

本书旨在为想要学习使用最新 Unity 版本构建高性能游戏的开发者提供优化技术。

本书涵盖的内容

第一章,评估性能问题,提供了对 Unity Profiler 的探索,以及一系列用于分析我们的应用程序、检测性能瓶颈和进行根本原因分析的方法。

第二章,脚本策略,讨论了我们的 Unity C#脚本代码的最佳实践,最小化 MonoBehaviour 回调开销,改进对象间通信等。

第三章,批处理的好处,探讨了 Unity 的动态批处理和静态批处理系统,以及如何利用它们来减轻渲染管道的负担。

第四章,优化您的艺术资产,帮助您了解艺术资产背后的技术,并学习如何避免导入、压缩和编码中的常见陷阱。

第五章,更快的物理,探讨了 Unity 内部物理引擎的细微差别,无论是用于 3D 还是 2D 游戏,以及如何正确组织我们的物理对象以改善性能。

第六章,动态图形,深入探讨了渲染管线,以及如何改善在 GPU 或 CPU 中遭受渲染瓶颈的应用程序,如何优化图形效果,如光照、阴影和粒子效果,优化着色器代码的方法,以及针对移动设备的特定图形优化。

第七章,虚拟和增强现实优化,专注于 VR 和 AR 等新兴娱乐媒介,并包括一些针对这些平台构建的应用程序的性能优化独特技术。

第八章,精湛的内存管理,探讨了 Unity 引擎、Mono 框架的内部工作原理,以及如何在这些组件中管理内存以保护我们的应用程序免受过多的堆分配和运行时垃圾回收的影响。

第九章,面向数据的技术堆栈,探讨了 Unity 针对多线程密集型游戏的新优化:DOTS。我们介绍了新的 C#作业系统、新的 Unity ECS 和 burst 编译器。

第十章,战术技巧和窍门,以 Unity 专业人士使用的众多实用技术结束本书,这些技术用于改善项目工作流程和场景管理。

为了充分利用本书

本书的大部分内容将专注于适用于 Unity 2019 和 Unity 2020 的功能和增强。本书中探讨的许多技术也可以应用于 Unity 2018 项目以及更早的版本,但某些功能可能会有所不同。这些差异将在适用的情况下进行突出显示。

值得注意的是,代码本应适用于 Unity 2020,但在撰写本文时,我们只能在 alpha 版本上对其进行测试。当 Unity 2020 退出 alpha 版本时,可能还会出现其他不兼容性。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Unity-Game-Optimization-Third-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。去看看吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781838556518_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“这些可以通过 UnityEngine.Profiling.Profiler 类及其 BeginSample()EndSample() 方法访问。”

代码块设置如下:

void DoSomethingCompletelyStupid() { 
  Profiler.BeginSample("My Profiler Sample");  
  List<int> listOfInts = new List<int>();  
  for(int i = 0; i < 1000000; ++i) {    
    listOfInts.Add(i);  
  }
  Profiler.EndSample();
}

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“当 Unity 应用程序以开发模式编译时。”

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

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

联系我们

我们欢迎读者的反馈。

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

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

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

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.

评论

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

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

第一部分:基础脚本优化

读者将学习如何使用内置的剖析器识别性能瓶颈,以及如何修复最常见的问题。本节中的章节如下:

  • 第一章,评估性能问题

  • 第二章,脚本策略

第一章:评估性能问题

对于大多数软件产品来说,性能评估是一个非常科学的过程。首先,我们确定最大/最小支持的性能指标,例如允许的内存使用量、可接受的 CPU 消耗量和并发用户数。接下来,我们在针对目标平台构建的应用程序版本上进行负载测试,同时收集仪表数据。一旦收集到这些数据,我们就分析和搜索以寻找性能瓶颈。如果发现问题,我们完成根本原因分析RCA),然后更改配置或应用程序代码以修复问题并重复测试。

虽然游戏开发是一个非常艺术的过程,但它仍然非常技术性。我们的游戏应该有一个目标受众,这可以告诉我们游戏可能运行在哪些硬件限制下,也许还能告诉我们确切需要达到的性能目标(尤其是在控制台和移动游戏的情况下)。我们可以在应用程序上执行运行时测试,从多个子系统(CPU、GPU 内存、物理引擎、渲染管线等)收集性能数据,并将它们与我们认为可接受的数据进行比较。然后我们可以使用这些数据来识别应用程序中的瓶颈,进行额外的仪表测量,并确定问题的根本原因。最后,根据问题的类型,我们应该能够应用多种解决方案来提高应用程序的性能。

然而,在我们花费哪怕一秒钟时间进行性能优化之前,我们首先需要证明确实存在性能问题。在没有充分理由的情况下花费时间重写和重构代码是不明智的,因为预先优化很少值得麻烦。一旦我们有性能问题的证据,接下来的任务就是找出瓶颈的确切位置。确保我们理解性能问题发生的原因非常重要;否则,我们可能会浪费更多时间应用那些只是基于猜测的修复。这样做通常意味着我们只解决了问题的症状,而不是其根本原因,因此我们冒着它在未来以其他方式或我们尚未检测到的方式表现出来的风险。

在本章中,我们将探讨以下内容:

  • 如何使用 Unity Profiler 收集性能分析数据

  • 如何分析 Profiler 数据以确定性能瓶颈

  • 隔离性能问题和确定其根本原因的技术

对你可能会遇到的问题有深入的理解后,你将准备好阅读剩余章节中提供的信息,在那里你将学习到针对我们检测到的问题类型有哪些解决方案可用。

使用 Unity Profiler 收集性能分析数据

Unity Profiler 集成在 Unity 编辑器本身中,提供了一种便捷的方法,在运行时通过生成关于众多 Unity3D 子系统的使用和统计报告来缩小我们寻找性能瓶颈的范围。它可以收集数据的不同子系统如下:

  • CPU 消耗(每个主要子系统)

  • 基本和详细的渲染和 GPU 信息

  • 运行时内存分配和总体消耗

  • 音频源/数据使用

  • 物理引擎(2D 和 3D)使用

  • 网络消息和操作使用

  • 视频播放使用

  • 基本和详细的用户界面性能

  • 全局照明GI)统计

通常有两种方法来利用性能分析工具:仪表化基准测试(尽管,诚然,这两个术语经常被互换使用)。

仪表化通常意味着通过观察目标函数调用的行为、内存分配的多少以及/或者在哪里分配,来仔细研究应用程序的内部工作原理,从而获得一个准确的情况,希望找到问题的根本原因。然而,这通常不是开始识别性能问题的有效方法,因为任何应用程序的性能分析都会带来其自身的性能成本。

当 Unity 应用程序以开发模式编译时(由构建设置菜单中的开发构建标志确定),将启用额外的编译器标志,导致应用程序在运行时生成特殊事件,这些事件会被 Profiler 记录并存储。自然地,这将在运行时由于应用程序承担的所有额外工作负载而造成额外的 CPU 和内存开销。更糟糕的是,如果应用程序通过 Unity 编辑器进行性能分析,那么还会产生更多的 CPU 和内存使用,确保编辑器更新其界面,渲染额外的窗口(例如场景窗口),并处理后台任务。这种性能分析的成本并不总是可以忽略不计的。在过度庞大的项目中,有时在启用 Profiler 时可能会引起各种不一致和意外的行为:Unity 可能会耗尽内存,一些脚本可能拒绝运行,物理引擎可能停止更新(一帧所用的时间可能如此之大,以至于物理引擎达到了每帧允许的最大更新次数),等等。这是我们为了深入分析代码在运行时的行为而必须付出的必要代价,我们应该始终意识到其影响。因此,在我们开始分析应用程序中的每一行代码之前,做一些基准测试会更明智。

基准测试涉及对应用程序进行表面级别的测量。我们应该在游戏在目标硬件上运行时的运行时会话中收集一些基本数据并执行测试场景;测试用例可以是,例如,几秒钟的游戏玩法,播放一段场景,或者一个级别的部分游玩。这个活动的目的是了解用户可能会经历什么,并持续关注性能明显变差的时刻。这些问题可能严重到需要进一步分析。

在执行基准测试过程中,我们感兴趣的指标通常是每秒渲染的帧数(FPS),整体内存消耗,CPU 活动如何(寻找活动的大峰值),有时还有 CPU/GPU 温度。这些都是相对简单的指标,可以用作性能分析的第一步,原因之一是:从长远来看,这将为我们节省大量的时间。它确保我们只花时间调查用户会注意到的问题。

我们应该在基准测试表明需要进一步分析之后,才深入进行仪器测试。如果我们想得到一个真实的数据样本,那么尽可能模拟实际平台行为进行基准测试也非常重要。因此,我们绝不应该接受通过编辑器模式生成的基准测试数据,因为编辑器模式附带一些额外的开销成本,可能会误导我们,或者隐藏真实应用程序中可能存在的潜在竞争条件。相反,我们应该在应用程序在目标硬件上以独立格式运行时将其分析工具连接到应用程序。

许多 Unity 开发者惊讶地发现,编辑器有时计算操作结果的速度比独立应用程序快得多。这在与序列化数据(如音频文件、预制件和可脚本对象)打交道时尤其常见。这是因为编辑器会缓存之前导入的数据,并且能够比真实应用程序更快地访问它。

现在,让我们来谈谈如何访问 Unity 分析器并将其连接到目标设备,这样我们就可以开始进行准确的基准测试。

已经熟悉将 Unity 分析器连接到其应用程序的用户可以跳转到名为“分析器窗口”的部分。

启动分析器

我们将开始一个简短的教程,介绍如何在各种环境中将我们的游戏连接到 Unity 分析器:

  • 通过编辑器或独立实例的应用程序本地实例

  • 在浏览器中运行的 WebGL 应用程序的本地实例

  • 在 iOS 设备上的应用程序远程实例(例如,iPhone 或 iPad)

  • 在 Android 设备上的应用程序远程实例(例如,Android 平板电脑或手机)

  • 分析编辑器本身

我们将简要介绍在每个上下文中设置分析器的要求。

编辑器或独立实例

在此实例中,访问分析器唯一的方法是通过 Unity 编辑器启动它,并将其连接到正在运行的应用程序实例。无论我们在编辑器中执行游戏(在 Playmode 下),在本地或远程设备上运行独立应用程序,还是希望分析编辑器本身,我们都会使用相同的分析器窗口。

要打开分析器,在编辑器中导航到窗口 | 分析 | 分析器,或使用 Ctrl + 7(或在 macOS 上为 cmd + 7):

图片

如果编辑器已经在 Playmode 下运行,那么我们应该会看到分析数据持续填充分析器窗口。

要分析独立项目,确保在构建应用程序时启用开发构建和分析器自动连接标志。

通过分析器窗口中的连接玩家选项选择是否分析基于编辑器的实例(通过编辑器的 Playmode)或独立实例(从编辑器外部分别构建和运行):

图片

注意,在分析单独的独立项目时切换回 Unity 编辑器将停止所有数据收集,因为应用程序在后台时不会更新。

连接到 WebGL 实例

分析器还可以连接到 Unity WebGL Player 的实例。这可以通过确保在从编辑器构建和运行 WebGL 应用程序时启用开发构建和分析器自动连接标志来实现。然后,应用程序将通过操作系统的默认浏览器启动。这使我们能够通过目标浏览器在更真实的环境中分析我们的基于 Web 的应用程序,并测试多种浏览器类型的行为一致性(尽管这要求我们不断更改默认浏览器)。

不幸的是,分析器连接只能在应用程序首次从编辑器启动时建立。目前无法连接到已在浏览器中运行的独立 WebGL 实例。这限制了 WebGL 应用程序的基准测试准确性,因为会有一些基于编辑器的开销,但这是我们目前唯一可用的选项。

远程连接到 iOS 设备

分析器还可以连接到在远程 iOS 设备上运行的活动的应用程序实例,例如 iPad 或 iPhone。这可以通过共享 Wi-Fi 连接来实现。

注意,只有当 Unity(以及分析器)在 Apple Mac 设备上运行时,才能远程连接到 iOS 设备。

观察以下步骤以将分析器连接到 iOS 设备:

  1. 确保在构建应用程序时启用开发构建和分析器自动连接标志。

  2. 将 iOS 设备和 macOS 设备连接到本地 Wi-Fi 网络,或连接到 ad hoc Wi-Fi 网络

  3. 通过 USB 或 Lightning 线将 iOS 设备连接到 macOS

  4. 按照常规使用 Build & Run 选项开始构建应用程序

  5. 在 Unity 编辑器中打开 Profiler 窗口,并选择“已连接玩家”下的设备

你现在应该能在 Profiler 窗口中看到 iOS 设备的性能数据收集情况。

Profiler 使用端口5499855511来广播性能数据。如果网络上有防火墙,请确保这些端口可用于出站流量。

为了解决构建 iOS 应用程序和将 Profiler 连接到它们的问题,请参考以下文档页面:docs.unity3d.com/Manual/TroubleShootingIPhone.html

远程连接到 Android 设备

将 Android 设备连接到 Unity Profiler 有两种不同的方法:通过 Wi-Fi 连接或使用Android 调试桥接器ADB)工具。这两种方法都可以在 Apple macOS 或 Windows PC 上使用。

执行以下步骤以通过 Wi-Fi 连接连接 Android 设备:

  1. 确保在构建应用程序时启用了开发构建和自动连接 Profiler 标志

  2. 将 Android 设备和桌面设备连接到本地 Wi-Fi 网络

  3. 通过 USB 线将 Android 设备连接到桌面设备

  4. 按照常规使用 Build & Run 选项开始构建应用程序

  5. 在 Unity 编辑器中打开 Profiler 窗口,并选择“已连接玩家”下的设备

应用程序应该通过 USB 连接构建并推送到 Android 设备,Profiler 应通过 Wi-Fi 连接。然后你应该能在 Profiler 窗口中看到 Android 设备的性能数据收集情况。

第二种选择是使用 ADB。ADB 是一套与 Android软件开发工具包SDK)捆绑的调试工具。对于 ADB 性能分析,请执行以下步骤:

  1. 通过遵循 Unity 的 Android SDK/NDK 设置指南来确保已安装 Android SDK:docs.unity3d.com/Manual/android-sdksetup.html

  2. 通过 USB 线将 Android 设备连接到您的桌面计算机

  3. 确保在构建应用程序时启用了开发构建和自动连接 Profiler 标志

  4. 按照常规使用 Build & Run 选项开始构建应用程序

  5. 在 Unity 编辑器中打开 Profiler 窗口,并选择“已连接玩家”下的设备

你现在应该能在 Profiler 窗口中看到 Android 设备的性能数据收集情况。

为了解决构建 Android 应用程序和将 Profiler 连接到它们的问题,请参考以下文档页面:docs.unity3d.com/Manual/TroubleShootingAndroid.html

编辑器性能分析

我们可以分析 Editor 本身。这通常用于尝试分析自定义编辑器脚本的性能。这可以通过在性能分析器窗口中启用“分析 Editor”选项,并将“已连接玩家”选项配置为 Editor 来实现,如以下屏幕截图所示:

图片

注意,如果我们想分析 Editor,则必须配置这两个选项:如果图表中没有发生任何操作,那么可能你没有选择“分析 Editor”按钮,或者你可能意外地连接到了另一个游戏构建版本!

性能分析器窗口

我们现在将介绍性能分析器在界面中的基本功能。

性能分析器窗口分为四个主要部分:

  • 性能分析器控制

  • 时间轴视图

  • 分解视图控制

  • 分解视图

这些部分在以下屏幕截图中显示:

图片

我们现在将详细介绍这些部分。

时间轴视图有很多颜色,但并不是每个人都以相同的方式看到颜色。幸运的是,如果你是色盲,Unity 已经为你考虑到了!在右上角的汉堡菜单中,你可以启用色盲模式:

图片

性能分析器控制

上一张截图中的顶部栏包含多个下拉和切换按钮,我们可以使用这些按钮来影响正在分析的内容以及从子系统收集数据的深度。这些内容将在下一节中介绍。

添加性能分析器

默认情况下,性能分析器将收集多个不同子系统的数据,这些子系统在时间轴视图中涵盖了 Unity 引擎的大多数子系统。这些子系统被组织成包含相关数据的各个区域。可以使用“添加性能分析器”选项添加额外的区域或恢复已删除的区域。请参阅时间轴视图部分,以获取我们可以分析的完整子系统列表。

Playmode

Playmode 下拉菜单允许我们选择要分析的目标 Unity 实例。这可以是当前的 Editor 应用程序、我们应用程序的本地独立实例,或者运行在远程设备上的我们应用程序的实例。

记录

启用“记录”选项(记录图标)会使性能分析器记录分析数据。在启用此选项时,这将持续发生。请注意,只有当应用程序正在积极运行时,才能记录运行时数据。对于在 Editor 中运行的应用程序,这意味着 Playmode 必须启用,并且它不应该暂停;或者,对于独立应用程序,它必须是活动窗口。如果启用了“分析 Editor”,则显示的数据将是针对 Editor 本身收集的。

深度分析

普通分析将仅记录由常见的 Unity 回调方法(如Awake()Start()Update()FixedUpdate())做出的时间和内存分配。启用深度分析选项会以更深的级别对脚本进行重新编译,使其能够测量每个被调用的方法。这导致在运行时产生显著更大的分析成本,并且由于在运行时收集整个调用栈的数据,因此使用大量内存。因此,深度分析可能甚至在大项目中都不可能进行,因为 Unity 可能在测试开始之前就耗尽内存,或者应用程序运行得如此缓慢,以至于测试变得毫无意义。

注意,切换深度分析需要整个项目完全重新编译后才能再次开始分析,因此最好避免在测试之间来回切换选项。

由于此选项盲目地测量整个调用栈,因此在大多数性能测试期间保持它启用是不明智的。此选项最好保留在默认分析不足以确定根本原因时,或者当我们测试小型测试场景的性能时,我们使用它来隔离某些活动。

如果需要为大型项目和场景进行深度分析,但深度分析选项在运行时过于阻碍,那么可以使用其他方法来执行更详细的分析;请参阅即将到来的标题为“代码段针对性分析”的部分。

分配调用栈

通过激活分配调用栈选项,Unity Profiler 将收集有关游戏内存分配的更多信息,而无需深度分析:

如果选项已启用,你可以点击代表内存分配的红框,Profiler 将显示该内存分配的来源和原因:

在层次结构视图中,相反,你仍然需要选择一个分配调用。然后,你需要切换到右上角的下拉菜单中的“显示相关对象”,然后选择一个 N/A 对象。之后,你将在下面的框中看到调用栈信息。

我们将在第八章“精湛的内存管理”中更多地讨论内存分配。

在撰写本文时,在 Unity 2019.1 中,分配调用栈仅在编辑器中进行性能分析时才工作。

清除

清除按钮将清除时间轴视图中的所有性能数据。

加载

Load 图标按钮将打开一个对话框窗口,以加载之前保存的性能数据(使用保存选项)。

保存

保存图标按钮将时间轴视图中当前显示的任何分析器数据保存到文件中。一次只能以这种方式保存 300 帧数据,并且必须手动创建新文件以保存更多数据。这通常对大多数情况来说足够了,因为当性能出现峰值时,我们就有大约五到十秒的时间暂停应用程序并保存数据以供将来分析(例如将其附加到错误报告)之前,它被推离时间轴视图的左侧。任何保存的分析器数据都可以通过加载选项加载到分析器中进行未来的检查。

帧选择

帧选择区域由几个子元素组成。帧计数器显示已分析了多少帧以及时间轴视图中当前选中的帧。有两个按钮可以向前或向后移动当前选中的帧一个帧,还有一个第三按钮(当前按钮),它将选中的帧重置为最新帧并保持该位置。这将导致分解视图在运行时分析期间始终显示当前帧的配置文件数据;它将显示“当前”一词。

时间轴视图

时间轴视图在运行时显示,

  • 右侧的配置文件数据的图形表示

  • 一系列复选框(以下截图中的彩色方块)用于在左侧启用/禁用不同的活动/数据类型:

图片

这些彩色框可以切换,这会改变在时间轴视图图形部分中相应数据类型的可见性。

当在时间轴视图中选择一个区域时,该子系统更详细的信息将在分解视图(位于时间轴视图下方)中显示,针对当前选中的帧。分解视图中显示的信息类型取决于时间轴视图中当前选中的区域。

可以通过单击区域右上角的 X 从时间轴视图中删除区域。如果您想再次显示已删除的区域,可以使用控制栏中的添加分析器选项。

在任何时候,我们都可以单击时间轴视图图形部分中的位置,以显示有关给定帧的信息。将出现一个大的垂直白色栏(通常在两侧有一些附加信息与线图相对应),显示我们已选择了哪个帧。

根据当前选中的区域(由当前突出显示为蓝色的区域确定),在分解视图中将提供不同的信息,在分解视图控制中也将提供不同的选项。更改选定的区域很简单,只需单击时间轴视图左侧或图形侧的相关框即可;然而,在图形区域内单击可能会更改选中的帧,所以如果您想查看同一帧的分解视图信息,请小心在图形区域内单击。

拆分视图控件

根据在时间线视图中当前选定的区域,拆分视图控件中会出现不同的下拉菜单和切换按钮选项。不同的区域提供不同的控件,这些选项决定了在拆分视图中可用的信息,以及如何呈现这些信息。

拆分视图

拆分视图揭示的信息将根据当前选定的区域和选定的拆分视图控件选项而有很大差异。例如,一些区域在拆分视图控件中的下拉菜单中提供不同的模式,这可以提供信息的简单或详细视图,甚至可以提供相同信息的图形布局,以便更容易解析。

现在,让我们分别介绍每个区域以及拆分视图中可用的不同类型的信息和选项。

CPU 使用区域

此区域显示所有 CPU 使用和统计信息。它可能是最复杂和最有用的,因为它涵盖了大量的 Unity 子系统,例如 MonoBehaviour 组件、相机、一些渲染和物理过程、用户界面(包括如果我们在通过编辑器运行时编辑器的界面)、音频处理、Profiler 本身,等等。

在拆分视图中显示 CPU 使用数据有三种不同的模式:

  • 层次模式

  • 原始层次模式

  • 时间线模式

让我们逐一查看这些模式:

  • 层次模式揭示了大多数调用栈调用,同时将相似的数据元素和全局 Unity 函数调用分组在一起以便于使用。例如,渲染分隔符,如 BeginGUI()EndGUI() 调用,在此模式中会合并在一起。层次模式有助于作为确定哪些函数调用执行所需 CPU 时间最多的初始第一步。

  • 原始层次模式与层次模式类似,但它将全局 Unity 函数调用分离成单独的条目,而不是将它们合并成一个整体条目。这可能会使拆分视图更难以阅读,但如果我们试图计算特定全局方法被调用的次数,或者确定这些调用中是否有任何调用比预期的消耗更多的 CPU/内存,这可能是有帮助的。例如,每个 BeginGUI()EndGUI() 调用都会被分离成不同的条目,使得与层次模式相比,每个调用被调用的次数更清晰。

对于 CPU 使用区域来说,最有用的模式可能是时间线模式选项(不要与主时间线视图混淆)。此模式将当前帧中的 CPU 使用情况与处理过程中调用栈的展开和收缩相一致地组织。

  • 时间轴模式将分解视图垂直组织成不同的部分,这些部分代表运行时不同的线程,例如主线程、渲染线程以及称为 Unity 作业系统的各种后台作业线程,用于加载场景和其他资产等活动。水平轴代表时间,因此较宽的块比较窄的块消耗了更多的 CPU 时间。水平尺寸也代表相对时间,这使得比较一个函数调用与另一个函数调用所花费的时间变得容易。垂直轴代表调用栈,因此较深的链表示在那时调用栈中的调用更多。

在时间轴模式下,分解视图顶部的块是 Unity 引擎在运行时调用的函数(或者技术上,回调函数),例如 Start()Awake()Update(),而它们下面的块是这些函数调用的函数,这可能包括其他组件上的函数或常规 C# 对象。

时间轴模式提供了一种非常干净和有序的方式来确定调用栈中哪个特定的方法消耗了最多时间,以及该处理时间与其他在同一帧中调用的方法相比如何。这使得我们可以以最小的努力评估造成性能问题的最大原因的方法。

例如,假设我们正在查看以下截图中的性能问题。我们可以快速地看出,有三个方法导致了问题,并且由于它们的宽度相似,它们各自消耗了相似的处理时间:

图片

在上一个截图中,我们通过调用三个不同的 MonoBehaviour 组件超出了我们的 16.667 毫秒预算。好消息是我们有三种可能的方法可以通过它们来找到性能改进,这意味着有很多机会找到可以改进的代码。坏消息是提高一个方法的速度只会改善该帧总处理时间的约三分之一。因此,可能需要检查和优化所有三个方法,才能回到预算之下。

在使用时间轴模式时,折叠 Unity 作业系统列表是个好主意,因为它往往会阻碍对主线程块中显示的项目可见性,而这可能是我们最感兴趣的。

通常,CPU 使用区域将最有用,用于检测可以通过第二章中探讨的解决方案解决的问题。

GPU 使用区域

GPU 使用区域类似于 CPU 使用区域,但它显示了在 GPU 上发生的函数调用和处理时间。此区域中的相关 Unity 函数调用将涉及相机、绘图、不透明和透明几何体、光照和阴影等。

GPU 使用区域提供了类似于 CPU 使用区域分层的信息,并估计调用各种渲染函数(如Camera.Render())所花费的时间(前提是在时间轴视图中当前选中的帧中确实发生了渲染)。

当你阅读第六章“动态图形”时,GPU 使用区域将是一个有用的参考工具。

渲染区域

渲染区域提供了一些通用的渲染统计信息,这些信息往往关注与为渲染准备 GPU 相关的活动,这涉及在 CPU 上发生的一系列活动(与在 GPU 内处理的渲染活动相对,渲染活动在 GPU 使用区域中详细说明)。拆分视图提供了有用的信息,例如 SetPass 调用次数(也称为绘制调用),渲染场景使用的批次数总和,从动态批处理和静态批处理中保存的批次数以及它们的生成方式,以及纹理消耗的内存。

渲染区域还提供了一个按钮来打开帧调试器,这将在第三章“批处理的好处”中进一步探讨。本区域剩余的信息将在你阅读第三章“批处理的好处”和第六章“动态图形”时变得非常有用。

内存区域

内存区域允许我们在拆分视图中以以下两种模式检查应用程序的内存使用情况:

  • 简单模式

  • 详细模式

简单模式仅提供子系统内存消耗的高级概述。这包括 Unity 的低级引擎、Mono 框架(垃圾收集器监视的总堆大小)、图形资产、音频资产和缓冲区,甚至用于存储 Profiler 收集的数据的内存。

详细模式显示单个 GameObject 和 MonoBehaviours 的内存消耗,包括它们的原生和托管表示。它还有一个列解释了为什么一个对象可能会消耗内存以及它何时可能会被释放。

垃圾收集器是 C#(Unity 首选脚本语言)提供的一个常见功能,它会自动释放我们为存储数据而分配的任何内存;但如果处理不当,它可能会使我们的应用程序在短时间内停滞。这个主题以及许多相关主题,例如原生和托管内存空间,将在第八章“精通内存管理”中探讨。

注意,信息仅在详细模式下通过手动采样(点击“获取样本 <目标名称>”按钮)出现。这是在详细模式下收集信息的唯一方法,因为为每次更新自动执行此类分析将过于昂贵:

图片

拆分视图还提供了一个标签为“收集对象引用”的按钮,可以收集有关某些对象的更深入内存信息。

内存区域将在我们深入研究内存管理、本地与托管内存以及第八章精湛的内存管理中的垃圾收集器复杂性时成为一个有用的工具。

音频区域

音频区域提供了音频统计概览,并且可以用来测量音频系统中的 CPU 使用情况以及音频源(包括播放或暂停的)和音频剪辑消耗的总内存。

拆分视图提供了许多关于音频系统如何运行以及各种音频通道和组如何被使用的有用见解。

当我们在第四章优化您的艺术资产中探索艺术资产时,音频区域可能会很有用。

在性能优化方面,音频往往被忽视,但如果管理不当,由于所需的硬盘访问和 CPU 处理量,音频可能会成为出人意料的瓶颈来源。不要忽视它!

3D 物理和 2D 物理区域

有两个不同的物理区域,一个用于 3D 物理(NVIDIA 的 PhysX),另一个用于 2D 物理系统(Box2D)。这个区域提供了各种物理统计信息,例如刚体、碰撞体和接触计数。

每个物理区域的拆分视图提供了对子系统内部工作的一些基本了解,但我们可以通过探索第五章更快的物理中将要介绍的物理调试器来获得更深入的见解。

网络消息和网络操作区域

这两个区域提供了关于 Unity 网络系统信息,该系统是在 Unity 5 发布周期中引入的。现有信息将取决于应用程序是否使用 Unity 提供的高级 APIHLAPI)或传输层 APITLAPI)。HLAPI 是一个易于使用的系统,用于自动管理玩家和GameObject的网络同步,而 TLAPI 则是一个位于套接字之上的薄层,允许 Unity 开发者构建自己的网络系统。

优化网络流量是一个单独占据整本书的主题,其中正确的解决方案通常非常依赖于应用程序的特定需求。这不会是一个 Unity 特定的问题,因此,网络流量优化的主题将不会在本书中进行探讨。

视频区域

如果我们的应用程序恰好使用了 Unity 的 VideoPlayer API,那么这个区域可能会对分析视频播放行为很有用。

媒体播放的优化也是一个复杂且非 Unity 特定的主题,本书将不会对其进行探讨。

UI 和 UI 详细信息区域

这些区域提供了关于使用 Unity 内置用户界面系统的应用程序的见解。如果我们使用的是自定义构建或第三方用户界面系统(如流行的 Asset Store 插件Next-Gen UINGUI)),那么这些区域可能提供的好处很少。

一个优化不良的用户界面可能会影响 CPU 和 GPU 中的一个或两个,因此我们将在第二章 脚本策略 中探讨 UI 的代码优化策略,并在第六章 动态图形 中探讨与图形相关的技术。

全局照明领域

全局照明领域为我们提供了对 Unity 的 GI 系统非常详细的洞察。如果我们的应用程序使用了 GI,那么我们应该参考这个领域来验证它是否运行正常。

在我们探索第六章 动态图形 中的光照和阴影时,这个领域可能会证明是有用的。

性能分析的最佳方法

良好的编码实践和项目资产管理通常使找到性能问题的根本原因相对简单,此时唯一真正的问题是弄清楚如何改进代码。例如,如果该方法只处理单个巨大的for循环,那么可以相当安全地假设问题可能是与循环执行的迭代次数有关,或者循环是否通过以非顺序方式读取内存而导致缓存未命中,每个迭代中完成的工作量,或者为下一次迭代做准备所需的工作量。

当然,无论我们是单独工作还是在团队环境中,我们的大部分代码并不总是以最干净的方式编写,我们应该预期有时需要分析一些糟糕的编码工作。有时,为了速度,我们被迫实施一些蹩脚的解决方案,而且我们并不总是有时间回头重构一切以保持最佳编码实践。事实上,许多以性能优化为名的代码更改往往显得非常奇怪或晦涩,通常使我们的代码库更难以阅读。软件开发的一个共同目标是编写干净、功能丰富且快速的代码。实现其中之一相对容易,但现实是,实现两个将花费更多的时间和精力,而实现所有三个几乎是不可能的。

在最基本层面上,性能优化只是另一种问题解决形式,当我们解决问题时忽略明显的问题,可能会犯下代价高昂的错误。我们的目标是使用基准测试来观察我们的应用程序,寻找问题行为的实例,然后使用仪器在代码中寻找关于问题起源的线索。不幸的是,由于我们过于急躁或忽略了细微的细节,很容易被无效数据分散注意力或得出结论。我们中的许多人都在软件调试过程中遇到过这样的情况,如果我们简单地挑战和验证早期的假设,我们就能更快地找到问题的根源。追查性能问题也是如此。

一个任务清单将有助于我们专注于问题,并确保我们不会浪费时间尝试实现任何没有影响主要性能瓶颈的优化。当然,每个项目都是不同的,都有其独特的挑战需要克服,但以下清单足够通用,应该适用于任何 Unity 项目:

  • 确认目标脚本存在于场景中

  • 验证脚本在场景中出现的次数是否正确

  • 验证事件顺序的正确性

  • 最小化持续代码更改

  • 最小化内部干扰

  • 最小化外部干扰

验证脚本存在

有时,我们会期望看到某些东西,但并没有看到。这些通常很容易发现,因为人脑在模式识别和发现我们未预期的差异方面非常出色。然而,也有时候我们假设某些事情正在发生,但实际上并没有。这些通常更难注意到,因为我们经常在寻找第一种问题,并假设我们没有看到的东西是按预期工作的。在 Unity 的上下文中,一个问题就是验证我们期望运行的脚本实际上是否存在于场景中。

可以通过在层次结构窗口文本框中输入以下内容来快速验证脚本的存在:

t:<monobehaviour name>

例如,将t:mytestmonobehaviour(注意它不区分大小写)输入到层次结构文本框中,将显示所有当前至少附加了一个MyTestMonoBehaviour脚本的 GameObject 的简短列表。

注意,此简短列表功能还包括任何具有从给定脚本名称派生的组件的 GameObject。

我们还应该再次检查它们附加到的 GameObject 是否仍然处于启用状态,因为我们可能在之前的测试中禁用了它们,可能是由于有人或某物意外地关闭了该对象。

验证脚本数量

如果我们在查看 Profiler 数据时注意到某个MonoBehaviour方法被执行的次数比预期多,或者执行时间比预期长,我们可能想要再次确认它在场景中出现的次数与我们预期的次数相符。完全有可能有人在场景文件中创建了比预期更多的对象,或者我们可能意外地从代码中实例化了比预期更多的对象。如果是这样,问题可能是由于冲突或重复的方法调用产生了性能瓶颈。我们可以使用与最佳性能分析方法部分中使用的相同的方法来验证数量。

如果我们期望场景中出现特定数量的组件,但简短列表显示出现了更多(或更少!)这些组件,那么编写一些初始化代码来防止这种情况再次发生可能是明智的。我们还可以编写一些自定义编辑器辅助工具,向可能犯这种错误的所有级别设计师显示警告。

防止此类偶然错误对于提高生产力至关重要,因为经验告诉我们,如果我们没有明确禁止某事,那么无论何时何地,出于何种原因,总有人会这样做。这很可能会让我们花费一个令人沮丧的下午去追踪一个最终证明是由人为错误引起的问题。

验证事件顺序

Unity 应用程序主要作为从Native 代码Managed 代码的一系列回调操作。这一概念将在第八章精通内存管理中更详细地解释,但为了简要总结,Unity 的主线程并不像简单的控制台应用程序那样运行。在这样的应用程序中,代码会从一个明显的起点(通常是main()函数)执行,然后我们会直接控制游戏引擎,初始化主要子系统,然后游戏在一个大的while循环(通常称为游戏循环)中运行,该循环检查用户输入,更新游戏,渲染当前场景,并重复。这个循环只有在玩家选择退出游戏时才会退出。

相反,Unity 为我们处理游戏循环,我们期望Awake()Start()Update()FixedUpdate()等回调在特定时刻被调用。最大的不同之处在于,我们没有对同一类型事件调用的顺序进行精细控制。当加载新场景(无论是游戏的第一场景还是后来的场景)时,每个MonoBehaviour组件的Awake()回调都会被调用,但无法预测这一发生的顺序。

因此,如果我们有一组对象在它们的Awake()回调中配置一些数据,然后另一组对象在其自己的Awake()回调中使用这些配置数据,那么一些场景对象的重新组织或重建,或者代码库或编译过程中的随机变化(不清楚确切原因是什么)可能会改变这些Awake()调用的顺序,然后依赖的对象可能会尝试使用我们没有预期初始化的数据。对于MonoBehaviour组件提供的所有其他回调,例如Start()Update(),也是如此。

在任何足够复杂的项目中,都无法确定同一类型的回调在 MonoBehaviour 组件组中被调用的顺序,因此我们应该非常小心,不要假设对象回调会按照特定的顺序发生。实际上,永远不要编写假设这些回调需要按照特定顺序调用的代码是一种基本实践,因为这可能在任何时候导致崩溃。

处理后期初始化的更好地方是在 MonoBehaviour 组件的 Start() 回调中,这个回调总是在每个对象的 Awake() 回调之后调用,并在第一次 Update() 调用之前。后期更新也可以在 LateUpdate() 回调中进行。

如果你难以确定事件的实际顺序,那么这最好通过在 IDE(MonoDevelop、Visual Studio 等等)中进行逐步调试或通过使用 Debug.Log() 打印简单的日志语句来处理。

警告:Unity 的日志记录器臭名昭著地昂贵。日志记录不太可能改变回调的顺序,但如果使用过于激进,它可能会引起一些不希望的性能峰值。要聪明,只在代码库中最相关的部分进行有针对性的日志记录。

协程通常用于编写一系列事件的脚本,它们何时被触发将取决于所使用的 yield 类型。可能最难调试且不可预测的类型是 WaitForSeconds yield 类型。Unity 引擎是非确定性的,这意味着在不同的会话中,即使是在相同的硬件上,你也会得到略有不同的行为。例如,在一个会话中,你可能会在应用运行的第一秒内调用 60 次更新,在下一个会话中是 59 次,而在下一个会话中是 62 次。在另一个会话中,你可能会在第一秒内得到 61 次更新,然后是 60 次,接着是 59 次。

在协程开始和结束之间,将调用不同数量的 Update() 回调,因此如果协程依赖于某个东西被调用特定次数的 Update() 函数,我们就会遇到问题。最好保持协程的行为简单且一旦开始就无需依赖其他行为。打破这条规则可能很有诱惑力,但基本上可以保证未来的某些更改将以意想不到的方式与协程交互,导致长时间、痛苦的调试会话,并引发一个难以重现的游戏破坏性错误。

最小化持续代码更改

为了追踪性能问题而更改应用程序的代码最好谨慎进行,因为随着时间的推移,这些更改很容易被忘记。向我们的代码中添加调试日志语句可能很有吸引力,但请记住,引入这些调用、重新编译我们的代码以及分析完成后移除这些调用都会花费我们时间。此外,如果我们忘记移除它们,那么它们可能会在最终构建中产生不必要的运行时开销,因为 Unity 的调试控制台窗口日志在 CPU 和内存方面可能非常昂贵。

解决这个问题的好方法是在我们进行更改的地方添加一个标志或注释,这样就可以轻松找到并在以后移除它。希望我们也很明智地使用源控制工具来管理我们的代码库,这样就可以轻松区分任何修改文件的更改内容,并将它们恢复到原始状态。这是确保不必要的更改不会进入最终版本的一个极好方法。当然,这绝对不是一种保证的解决方案,如果我们同时应用了一个修复程序,并且在提交更改之前没有仔细检查所有修改的文件。

在运行时调试期间使用断点是首选方法,因为我们能够追踪完整的调用栈、变量数据和条件代码路径(例如,if-else 块),而不会冒任何代码更改的风险或浪费时间在重新编译上。当然,如果我们试图弄清楚在成千上万的帧中发生奇怪现象的原因,这通常不是一个选项。在这种情况下,最好确定一个阈值值来查找,并添加一个包含断点的 if 语句,当值超过阈值时,该语句将被触发。

最小化内部干扰

Unity 编辑器有其独特的怪癖和细微差别,这有时会使调试某些类型的问题变得令人困惑。

首先,如果单个帧处理时间过长,以至于我们的游戏明显冻结,那么分析器可能无法捕捉到结果并在分析器窗口中记录它们。如果我们希望在应用程序/场景初始化期间捕获数据,这可能会特别令人烦恼。稍后将在 自定义 CPU 分析 部分提供一些替代方案,以探索解决此问题的方法。

一个常见的错误(我在撰写本书的过程中不幸多次成为受害者)是,如果我们试图通过按键来启动测试,并且已经打开了 Profiler 窗口,那么在触发按键之前,我们不应该忘记点击回到 Editor 的 Game 窗口。如果 Profiler 是最近点击的窗口,那么 Editor 将向该窗口发送按键事件,而不是运行中的应用程序,因此,没有任何GameObject会捕获该按键的事件。这也适用于 GameView 的渲染任务,甚至使用WaitForEndOfFrameyield 类型的协程。如果 Game 窗口在 Editor 中不可见且未激活,那么该视图就不会有任何渲染,因此,依赖于 Game 窗口渲染的事件将不会被触发。请注意!

垂直同步(通常称为 VSync)用于将应用程序的帧率与显示其的设备的帧率相匹配;例如,一个显示器可能以 60 赫兹(每秒 60 次循环,大约 16 毫秒)运行。如果我们的游戏中的渲染循环运行速度比显示器循环快——例如,10 毫秒——那么游戏将等待另一个 6 毫秒,然后输出渲染的帧。这个功能减少了屏幕撕裂,屏幕撕裂发生在新图像在旧图像完成之前推送到显示器上,并且在新图像的短暂时刻,新图像的一部分与旧图像重叠。

使用 VSync 启用 Profiler 可能会在WaitForTargetFPS标题下的 CPU Usage 区域产生大量的噪声峰值,因为应用程序有意减慢自身速度以匹配显示器的帧率。这些峰值在编辑器模式下通常看起来非常大,因为编辑器通常渲染到一个非常小的窗口,这不需要太多的 CPU 或 GPU 工作来渲染。

这将产生不必要的杂乱,使得难以发现真正的问题。我们应该确保在性能测试期间寻找 CPU 峰值时,在 CPU Usage 区域禁用 VSync 复选框。我们可以通过导航到 Edit | Project Settings | Quality 然后转到当前选定平台的子页面来完全禁用 VSync 功能。

我们还应该确保性能下降不是大量异常和错误消息直接出现在 Editor 控制台窗口的结果。Unity 的Debug.Log()以及类似的Debug.LogError()Debug.LogWarning()方法,在 CPU 使用率和堆内存消耗方面非常昂贵,这可能导致垃圾回收发生,从而造成更多的 CPU 周期损失(有关这些主题的更多信息,请参阅第八章,精通内存管理)。

Debug.LogWarning(),在 CPU 使用率和堆内存消耗方面非常昂贵,这可能导致垃圾回收发生,从而造成更多的 CPU 周期损失(有关这些主题的更多信息,请参阅第八章,精通内存管理)。

这种开销对于在编辑器模式下查看项目的普通人来说通常是不可察觉的,因为大多数错误来自编译器或配置不当的对象。然而,在运行时过程中,尤其是在分析时,它们可能会成为问题,尤其是在我们希望在没有外部干扰的情况下观察游戏运行时。例如,如果我们遗漏了一个应该通过编辑器分配的对象引用,并且它在 Update() 回调中使用,那么单个 MonoBehaviour 实例可能会在每次更新时抛出新的异常。这会给我们的分析数据添加很多不必要的噪音。

注意,我们可以通过下一张截图中的按钮隐藏不同的日志级别类型。即使这些额外的日志没有被渲染,它们仍然需要 CPU 和内存来执行,但它们确实允许我们过滤掉我们不需要的垃圾信息。然而,通常来说,保持所有这些选项都启用是一个好的实践,以确保我们没有错过任何重要的事情:

图片

最小化外部干扰

这一点很简单,但绝对必要。我们应该检查是否有后台进程正在消耗 CPU 周期或消耗大量内存。内存不足通常会影响我们的测试,因为它可能导致更多的缓存未命中,对虚拟内存页面交换文件的硬盘访问,以及应用程序响应速度的普遍减慢。如果我们的应用程序突然表现远低于预期,请检查系统的任务管理器(或等效工具)中是否有任何可能导致问题的 CPU/内存/硬盘活动。

代码段的目标分析

如果之前提到的清单没有解决我们的性能问题,那么我们可能真的遇到了需要进一步分析的问题。Profiler 窗口能够有效地显示性能的总体概述;它可以帮助我们找到需要调查的具体帧,并可以快速告知我们哪个 MonoBehaviour 和/或方法可能存在问题。然后我们需要确定问题是否可重现,在什么情况下性能瓶颈出现,以及问题确实是从有问题的代码块中的哪个部分开始的。

为了完成这些,我们需要对我们代码的目标部分进行一些分析,并且有一些有用的技术我们可以用于这项任务。对于 Unity 项目,它们基本上可以分为两大类:

  • 从脚本代码控制 Profiler

  • 自定义计时和日志方法

注意,下一节将重点介绍如何通过 C# 代码来调查脚本瓶颈。其他引擎子系统的瓶颈来源将在相关章节中讨论。

Profiler 脚本控制

可以通过脚本代码通过Profiler类来控制分析器。这个类中有几个有用的方法,我们可以在 Unity 文档中探索,但最重要的方法是激活和停用运行时分析的定界符方法。这些方法可以通过UnityEngine.Profiling.Profiler类通过其BeginSample()EndSample()方法访问。

注意,定界符方法BeginSample()EndSample()仅在开发构建中编译,因此它们在未勾选开发模式的情况下不会编译或执行,这通常被称为非操作,或no-op代码。

BeginSample()方法有一个重载,允许为样本指定一个自定义名称,使其在 CPU 使用区域的层次结构模式下显示。例如,以下代码将分析此方法的调用,并将数据显示在自定义标题下的分解视图中,如下所示:

void DoSomethingCompletelyStupid() { 
  Profiler.BeginSample("My Profiler Sample");  
  List<int> listOfInts = new List<int>();  
  for(int i = 0; i < 1000000; ++i) {    
    listOfInts.Add(i);  
  }
  Profiler.EndSample();
}

您可以从www.packtpub.com下载您购买的所有Packt Publishing书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

我们应该预期,调用这个设计不佳的方法(该方法生成一个包含一百万个整数的List,然后对其绝对不做任何处理)将导致 CPU 使用率激增,消耗数兆字节的内存,并在“我的分析器样本”标题下的分析器分解视图中出现,如下面的截图所示:

截图

自定义 CPU 分析

分析器只是我们可用的工具之一。有时,我们可能想要对我们的代码进行自定义分析和记录。也许我们不确定 Unity 分析器是否给出了正确的答案,也许我们认为它的开销成本太大,或者也许我们只是喜欢对我们的应用程序的每个方面都拥有完全的控制。无论如何,了解一些进行独立代码分析的技术是有用的技能。毕竟,我们不太可能在整个游戏开发生涯中只使用 Unity。

分析工具通常非常复杂,所以我们不太可能在合理的时间内自己生成一个可比较的解决方案。当涉及到测试 CPU 使用率时,我们真正需要的只是一个精确的计时系统、一种快速、低成本的记录信息的方法,以及一些用于测试的代码。碰巧的是,.NET 库(或者技术上讲,Mono 框架)在System.Diagnostics命名空间下提供了一个Stopwatch类。我们可以在任何时候停止和启动Stopwatch对象,并且我们可以轻松地获取自Stopwatch对象启动以来经过的时间。

不幸的是,这个类并不完全准确;它最多只能精确到毫秒,或者最多到十分之一毫秒。当开始深入研究时,使用 CPU 时钟以高精度、实时方式计数可以是一个令人惊讶的困难任务。因此,为了避免对这一主题进行详细讨论,我们应该尝试找到一种方法,让Stopwatch类满足我们的需求。

如果精度很重要,那么一种有效的方法是多次运行相同的测试。假设测试代码块既容易重复,又不特别长,我们应该能够在合理的时间内运行数千次,甚至数百万次测试,然后将总耗时除以我们刚刚运行的测试次数,以获得单个测试的更准确时间。

在我们沉迷于高精度的话题之前,我们首先应该问自己我们是否真的需要它。大多数游戏期望以 30 FPS 或 60 FPS 的速度运行,这意味着它们只有大约 33 毫秒或 16 毫秒的时间来计算整个帧的所有内容。所以,假设我们只需要将特定代码块的性能降低到 10 毫秒以下,那么重复测试数千次以获得微秒级的精度与目标相差太远,不值得。

以下是一个自定义计时器的类定义,它使用Stopwatch对象来计算给定次数的测试时间:

using System;
using System.Diagnostics;

public class CustomTimer : IDisposable {
  private string _timerName;
  private int _numTests;
  private Stopwatch _watch;

  // give the timer a name, and a count of the 
  // number of tests we're running
  public CustomTimer(string timerName, int numTests) {
    _timerName = timerName;
    _numTests = numTests;
    if (_numTests <= 0) {
      _numTests = 1;
    }
    _watch = Stopwatch.StartNew();
  }

    // automatically called when the 'using()' block ends
    public void Dispose() {
    _watch.Stop();
    float ms = _watch.ElapsedMilliseconds;
    UnityEngine.Debug.Log(string.Format("{0} finished: {1:0.00} " + 
        "milliseconds total, {2:0.000000} milliseconds per-test " + 
        "for {3} tests", _timerName, ms, ms / _numTests, _numTests));
    }
}

在成员变量名前添加下划线是区分类的成员变量(也称为字段)和方法参数以及局部变量的常见且有用的方式。

以下是一个CustomTimer类使用的示例:

const int numTests = 1000;
using (new CustomTimer("My Test", numTests)) {
  for(int i = 0; i < numTests; ++i) {
    TestFunction();
  }
} // the timer's Dispose() method is automatically called here

使用这种方法时,有三个需要注意的事项:

  • 首先,我们只是在多个方法调用中取平均值。如果调用之间的处理时间差异很大,那么这最终的平均值将不会很好地反映出来。

  • 其次,如果内存访问很常见,那么反复请求相同的内存块将导致人为地提高缓存命中率(因为 CPU 最近访问了相同的区域,所以可以在内存中非常快速地找到数据),与典型调用相比,这会降低平均时间。

  • 第三,即时编译JIT)的效果将由于类似的人工原因而有效地被隐藏,因为它只影响方法的第一次调用。JIT 编译是.NET 的一个特性,将在第八章“精通内存管理”中更详细地介绍。

using块通常用于在作用域结束时安全地确保未托管资源被正确销毁。当using块结束时,它将自动调用对象的Dispose()方法来处理任何清理操作。为了实现这一点,对象必须实现IDisposable接口,这迫使它定义Dispose()方法。

然而,相同的语言特性也可以用来创建一个独立的代码块,该代码块创建一个短期对象,当代码块结束时自动处理一些有用的东西;这就是它在前面代码块中的使用方式。

注意,using 块不应与用于在脚本文件开头引入附加命名空间的 using 语句混淆。在 C# 中用于管理命名空间的关键字与另一个关键字存在命名冲突,这非常具有讽刺意味。

因此,using 块和 CustomTimer 类为我们提供了一个干净的方式来包装我们的测试代码,使得使用的时间和地点一目了然。

另一件需要担心的事情是应用程序的预热时间。当场景开始时,Unity 需要从磁盘加载大量数据,初始化复杂的子系统,如物理和渲染系统,以及需要解决的各种 Awake()Start() 回调调用,这给 Unity 带来了显著的开销。这种早期开销可能只持续一秒钟,但如果代码也在这个早期初始化期间执行,这可能会对我们的测试结果产生重大影响。这使得,如果我们想要一个准确的测试,那么任何运行时测试都应该在应用程序达到稳定状态后才开始。

理想情况下,我们能够在初始化完成后在它自己的场景中执行目标代码块。这并不总是可能的;因此,作为备用计划,我们可以在目标代码块周围包裹一个 Input.GetKeyDown() 检查,以便在它被调用时控制它。例如,以下代码只有在按下空格键时才会执行我们的测试方法:

if (Input.GetKeyDown(KeyCode.Space)) {
  const int numTests = 1000;
  using (new CustomTimer("Controlled Test", numTests)) {
    for(int i = 0; i < numTests; ++i) {
      TestFunction();
    }
  }
}

如前所述,Unity 的控制台窗口日志机制成本极高。因此,我们应该尽量避免在性能测试过程中(或者游戏过程中)使用这些日志方法。如果我们发现自己迫切需要打印出大量单独消息的详细性能数据(例如,通过在循环中执行计时测试以确定哪个迭代花费的时间比其他迭代多),那么最好是将日志数据缓存起来,并在结束时全部打印出来,就像 CustomTimer 类所做的那样。这将减少运行时开销,但会以一些内存消耗为代价。另一种选择是在测试过程中打印每个 Debug.Log() 消息会损失许多毫秒,这会污染结果。

CustomTimer类也使用了string.Format()。这将在第八章精湛的内存管理中更详细地介绍,但简短的解释是,这个方法被使用是因为使用+运算符(例如,如Debug.Log("Test: " + output);这样的代码)生成自定义的string对象会导致出人意料的大量内存分配,这会吸引垃圾收集器的注意。否则,将与我们实现准确计时和分析的目标相冲突,应避免这样做。

关于性能分析和分析的最终思考

关于性能优化的思考方式是去除不必要的任务,这些任务浪费了宝贵的资源。我们可以做同样的事情,通过最小化任何浪费的努力来最大化我们的生产力。有效使用我们可用的工具至关重要。通过保持对一些最佳实践和技术保持警觉,我们可以优化自己的工作流程。

对于如何正确使用任何类型的数据收集工具的建议,大部分可以总结为三种不同的策略:

  • 理解工具

  • 减少噪声

  • 关注问题

理解性能分析器

性能分析器是一个设计精良且直观的工具,因此通过花一两个小时用测试项目探索其选项并阅读其文档,就可以理解其大多数功能集。我们对工具的了解越多,包括其优点、缺点、功能和限制,我们就能更好地理解它提供的信息,因此花时间在游乐场环境中使用它是值得的。我们不希望距离发布还有两周,有一百个性能缺陷需要修复,却不知道如何有效地进行性能分析。

例如,始终意识到时间轴视图图形显示的相对性质。时间轴视图不提供其垂直轴上的值,并自动根据最后 300 帧的内容调整此轴;由于相对变化,它可以使小的峰值看起来比实际情况更严重。因此,即使时间轴中的峰值或静止状态看起来很大且具有威胁性,也不一定意味着存在性能问题。

时间轴视图中的几个区域提供了有用的基准条,这些条目以带有时间和 FPS 值的水平线形式出现。这些条目应用于确定问题的严重程度。不要让性能分析器误导我们,认为大的峰值总是不好的。一如既往,只有当用户会注意到它时,它才重要。

例如,如果 CPU 使用量的大幅激增没有超过 60 FPS 或 30 FPS 的基准条(取决于应用程序的目标帧率),那么明智的做法是忽略它,并在其他地方寻找 CPU 性能问题,因为无论我们如何改进有问题的代码片段,它可能永远不会被最终用户注意到,因此这不是影响用户体验的关键问题。

减少噪声

噪声的经典定义(至少在计算机科学领域)是无意义的数据,而一批没有特定目标盲目捕获的分析数据总是充满了对我们不感兴趣的数据。更多的数据来源需要更多的时间来心理处理和筛选,这可能会非常分散注意力。避免这种情况的最好方法之一是简单地减少我们需要处理的数据量,通过去除任何被认为对当前情况非必要的数据。

减少分析器图形界面上的杂乱将使确定哪些子系统导致资源使用激增变得更容易。请记住,在时间轴视图区域的每个区域使用彩色复选框来缩小搜索范围。

警告:这些设置在编辑器中会自动保存,所以请确保您在下一个分析会话中重新启用它们,因为这可能会使我们错过下次重要的东西。

此外,可以禁用 GameObject 以防止它们生成分析数据,这也有助于减少我们的分析数据中的杂乱。这将自然地使每个我们禁用的对象略微提高性能。然而,如果我们逐渐禁用对象,并且当特定对象被禁用时性能突然变得显著可接受,那么很明显,该对象与问题的根本原因有关。

专注于问题

这个类别可能看起来是多余的,因为我们已经讨论了减少噪声的问题。我们剩下的应该就是手头的问题,对吧?并不完全是这样。专注是一种技能,它让我们不会让自己被无关紧要的任务和无谓的追求所分散注意力。

您会记得,使用 Unity 分析器进行性能分析会带来一定的性能成本。当使用深度分析选项时,这种成本会更严重。我们甚至可能通过添加额外的日志将更多的轻微性能成本引入我们的应用程序。如果搜索持续几个小时,很容易忘记何时何地引入了性能分析代码。

我们通过测量实际上是在改变结果。我们在数据采样期间实施的任何更改有时会导致我们追逐应用程序中不存在的错误,而如果我们尝试在没有额外的性能分析工具的情况下复制场景,我们本可以节省很多时间。如果瓶颈是可复制的,并且在没有性能分析的情况下可察觉,那么它就是开始调查的候选者。然而,如果新的瓶颈在现有调查过程中不断出现,那么请记住,它们可能是我们测试代码中引入的瓶颈,而不是新暴露的现有问题。

最后,当我们完成性能分析、完成修复并准备进行下一项调查时,我们应该确保再次对应用程序进行一次性能分析,以验证更改是否产生了预期的效果。

摘要

在本章中,你学到了很多关于如何在应用程序中检测和分析性能问题的知识。你了解了 Profiler 的许多功能和秘密,探索了各种策略以更实际的方法调查性能问题,并介绍了一系列不同的技巧和策略供你遵循。只要你欣赏背后的智慧并记住在可能的情况下利用它们,你就可以极大地提高你的生产力。

本章向我们介绍了识别需要改进的性能问题的技巧、策略和策略。在接下来的章节中,我们将探讨如何修复问题和尽可能提高性能的方法。所以,恭喜你首先完成了枯燥的部分。现在,我们将继续探讨 C#开发的最佳实践以及如何在 Unity 脚本中避免常见的性能陷阱。

第二章:脚本策略

由于脚本编写将消耗我们大量的开发时间,因此学习一些最佳实践将非常有益。脚本是一个非常广泛的概念,所以我们将尝试在本章中限制我们的讨论范围,专注于与 MonoBehaviours、GameObject 和相关功能有关的问题。

我们将在第八章“精湛的内存管理”中讨论 C#语言、.NET 库和 Mono 框架的细微差别和高级主题。

在本章中,我们将探讨以下方面的性能提升方法:

  • 在其他游戏对象中获取组件

  • 优化组件回调(Update()Awake()等)

  • 使用协程

  • 高效使用GameObjectTransform

  • 在不同对象之间交换消息

  • 优化数学计算

  • 在场景和 Prefab 加载期间进行序列化和反序列化

无论你是否有想要解决的特定问题,或者只是想学习一些未来参考的技术,本章将向你介绍一系列你可以用来现在和未来提高脚本编写工作的方法。在每种情况下,我们将探讨性能问题是如何产生以及为什么会产生,一个发生问题的示例情况,以及一个或多个解决这个问题的方案。

使用最快的方法获取组件

GetComponent()方法有几个变体,它们各自有不同的性能成本,因此明智的做法是调用这个方法最快的版本。可用的三个重载是GetComponent(string)GetComponent<T>()GetComponent(typeof(T))。实际上,最快的版本取决于我们正在运行的 Unity 版本,因为多年来对这些方法进行了多次优化;然而,如果你使用任何版本的 Unity(从 Unity 2017 开始),最好使用GetComponent<T>()变体。

让我们通过一些简单的测试来证明这一点:

int numTests = 1000000;
TestComponent test;
using (new CustomTimer("GetComponent(string)", numTests)) {
  for (var i = 0; i < numTests; ++i) {
    test = (TestComponent)GetComponent("TestComponent");
  }
}

using (new CustomTimer("GetComponent<ComponentName>", numTests)) {
  for (var i = 0; i < numTests; ++i) {
    test = GetComponent<TestComponent>();
  }
}

using (new CustomTimer("GetComponent(typeof(ComponentName))", numTests))  {
  for (var i = 0; i < numTests; ++i) {
    test = (TestComponent)GetComponent(typeof(TestComponent));
  }
}

之前的代码测试了GetComponent()的每个重载一百万次。这比典型项目中的测试数量要多得多,但它有助于使相对成本变得清晰。

这里是测试完成后我们得到的结果(当然,具体的数值可能因机器而异):

图片

如您所见,GetComponent<T>()方法仅比GetComponent(typeof(T))快一小部分,而GetComponent(string)则比其他替代方案慢得多。因此,由于性能差异很小,使用GetComponent()的基于类型的版本是相当安全的。然而,我们应该确保永远不使用GetComponent(string),因为结果相同,而且没有为所付出的成本带来任何好处。有一些非常罕见的例外。想象一下,如果我们正在编写一个用于 Unity 的自定义调试控制台,它可以解析用户输入的string以获取组件。在这种情况下,我们只有在调试和诊断情况下才会使用昂贵的GetComponent(string)来获取组件。在这些情况下,性能并不是很重要。相反,对于生产级应用程序,使用GetComponent(string)只是无谓地浪费 CPU 周期。

移除空的回调定义

Unity 中脚本的主要编写方式是在从MonoBehaviour派生的类中编写回调函数,我们知道 Unity 会在必要时调用这些函数。可能最常用的四个回调函数是Awake()Start()Update()FixedUpdate()

Awake()函数在MonoBehaviour首次创建时被调用,无论这是在场景初始化期间发生,还是在运行时从 Prefab 实例化包含MonoBehaviour组件的新GameObject实例时。Start()函数将在Awake()之后不久被调用,但在其第一次Update()之前。在场景初始化期间,每个MonoBehaviour组件的Awake()回调将在它们的Start()回调之前被调用。

之后,Update()函数将反复被调用,每次渲染管线呈现新的图像时都会调用一次。只要MonoBehaviour仍然存在于场景中,它仍然被启用,并且其父GameObject是活动的,Update()函数将持续被调用。

最后,FixedUpdate()函数在物理引擎更新之前被调用。固定更新在需要行为类似于Update()但不是直接绑定到渲染帧率,并且随时间更一致地被调用时使用。

请参考以下 Unity 文档中的页面,以了解各种 Unity 回调函数被调用的准确情况:docs.unity3d.com/Manual/ExecutionOrder.html

每当在我们的场景中首次实例化一个 MonoBehaviour 组件时,Unity 会将这些定义的回调函数添加到一个函数指针列表中,并在关键时刻调用它们。然而,重要的是要意识到,即使函数体为空,Unity 也会将这些回调函数钩入。核心 Unity 引擎并不知道这些函数体可能为空,只知道该方法已被定义,因此它必须获取它并在必要时调用它。因此,如果我们让这些回调函数的定义散布在代码库中,那么它们将因为引擎调用它们而产生的开销而浪费一小部分 CPU。

这可能是一个问题,因为每次我们在 Unity 中创建一个新的 MonoBehaviour 脚本文件时,它都会自动为我们生成两个样板回调函数,用于 Start()Update()

// Use this for initialization
void Start () {

}

// Update is called once per-frame
void Update () {

}

很容易不小心在实际上不需要这些函数的脚本上留下空定义。一个空的 Start() 定义可能会导致任何对象初始化得稍微慢一些,而没有任何合理的理由。这种影响可能对于少数几个 MonoBehaviours 来说并不明显,但随着项目的开发继续进行,我们将场景填充成千上万的具有许多空 Start() 定义的定制 MonoBehaviours,这可能会开始成为一个问题,每次通过 GameObject.Instantiate() 创建新的 Prefab 时,都会导致场景初始化缓慢和浪费 CPU 时间。

这种调用通常发生在关键的游戏事件期间;例如,当两个对象碰撞时,我们可能会生成一个粒子效果,创建一些漂浮的损伤文本,播放音效等等。这可能是性能的关键时刻,因为我们突然要求 CPU 进行大量的复杂更改,但在当前帧结束之前,我们只有有限的时间来完成它们。如果这个过程花费了太长时间,那么我们就会遇到帧率下降,因为渲染管线不允许在所有场景中的 Update() 回调(跨所有 MonoBehaviours)完成之前展示新的帧。因此,此时调用大量空的 Start() 定义是一种无谓的浪费,并可能在我们关键时刻的紧张时间预算中削减时间。

同时,如果我们的场景包含具有这些空 Update() 定义的成千上万的 MonoBehaviours,那么我们每帧都会浪费大量的 CPU 循环,这可能会对我们的帧率造成破坏。

让我们用一个简单的测试来证明所有这些。我们的测试场景应该包含具有两种类型组件的 GameObject,EmptyClassComponent,没有任何方法定义,以及 EmptyCallbackComponent,定义了一个空的 Update() 回调:

public class EmptyClassComponent : MonoBehaviour {
}

public class EmptyCallbackComponent : MonoBehaviour {
  void Update () {}
}

以下是对每种类型 30,000 个组件的测试结果。如果在运行时启用所有附加了 EmptyClassComponents 的 GameObject,那么在 Profiler 的 CPU 使用区域下将不会发生任何有趣的事情。会有一些少量的后台活动,但这些活动都不会由 EmptyClassComponents 引起。然而,一旦我们启用所有带有 EmptyCallbackComponent 的对象,我们就会观察到 CPU 使用量的巨大增加:

很难想象一个包含超过 30,000 个对象的场景,但请记住,MonoBehaviours 包含 Update() 回调,而不是 GameObjects。一个 GameObject 实例可以同时包含多个 MonoBehaviours,并且它们的每个子对象还可以包含更多的 MonoBehaviours,依此类推。几千个甚至一百个空的 Update() 回调将对帧率预算产生明显的影响,而没有任何潜在的好处。这在 Unity UI 组件中尤其常见,这些组件倾向于在非常深的层次结构中附加很多不同的组件。

解决这个问题很简单:删除空的回调定义。Unity 将没有任何东西可以挂钩,并且不会调用任何东西。在一个庞大的代码库中找到这样的空定义可能很困难,但如果我们使用一些基本的正则表达式(称为 regex),我们应该能够相对容易地找到我们想要的东西。

所有常见的 Unity 代码编辑工具,如 MonoDevelop、Visual Studio,甚至 Notepad++,都提供了一种在整个代码库上执行基于正则表达式的搜索的方法。请查阅工具的文档以获取更多信息,因为此方法可能因工具及其版本的不同而有很大差异。

以下正则表达式搜索应该能在我们的代码中找到任何空的 Update() 定义:

void\s*Update\s*?\(\s*?\)\s*?\n*?\{\n*?\s*?\}

此正则表达式检查 Update() 回调的标准方法定义,同时包括可能分布在整个方法定义中的任何多余的空白和换行符。

自然,所有上述内容也适用于非模板化的 Unity 回调,如 OnGUI()OnEnable()OnDestroy()LateUpdate()。唯一的区别是,只有 Start()Update() 在新脚本中自动定义。

查阅 Unity 的 MonoBehaviour 文档页面,以获取这些回调的完整列表,链接为 docs.unity3d.com/ScriptReference/MonoBehaviour.html

在我们的代码库中,似乎不太可能有人生成这么多空的回调版本,但永远不要说永远不可能。例如,如果我们在我们所有的自定义组件中普遍使用一个常见的基类,MonoBehaviour,那么该基类中的单个空回调定义将渗透整个游戏,这可能会给我们带来巨大的损失。特别小心 OnGUI() 方法,因为它可以在同一帧或 UI 事件中多次被调用。

在 Unity 脚本中,性能问题最常见的原因之一是误用Update()回调,通过执行以下一项或多项操作:

  • 反复重新计算很少或从不改变的价值

  • 有太多组件为可能共享的结果执行工作

  • 执行比必要更频繁的工作

值得养成记住的习惯,即我们编写的每一条代码,以及由这些回调调用的函数,都会消耗我们的帧率预算。为了达到 60 fps,我们每帧有 16.667 毫秒的时间来完成所有Update()回调中的工作。当我们开始原型设计时,这似乎是足够的,但在开发的中期,我们可能会开始注意到事情变得缓慢和响应迟钝,因为我们逐渐消耗了那个预算,这是由于我们无节制地想将更多东西塞入项目中的欲望。

让我们讨论一些直接解决这些问题的技巧。

缓存组件引用

在 Unity 脚本编写中,反复重新计算一个值是一个常见的错误,尤其是在使用GetComponent()方法时。例如,以下脚本代码试图检查一个生物的健康值,如果其健康值低于0,它将禁用一系列组件以准备死亡动画:

void TakeDamage() {

  Rigidbody rigidbody = GetComponent<Rigidbody>();
  Collider collider = GetComponent<Collider>();
  AIControllerComponent ai = GetComponent<AIControllerComponent>();
  Animator anim = GetComponent<Animator>();

  if (GetComponent<HealthComponent>().health < 0) {
    rigidbody.enabled = false;
    collider.enabled = false;
    ai.enabled = false;
    anim.SetTrigger("death");
  }
}

每次这个低效的方法执行时,它都会重新获取五个不同的组件引用。这对 CPU 使用率来说并不友好。如果主方法在Update()期间被调用,这尤其成问题。即使它没有被调用,它仍然可能与其他重要事件同时发生,例如创建粒子效果、用一个 Ragdoll 替换对象(从而在物理引擎中引发各种活动),等等。这种编码风格可能看似无害,但它可能会引起很多长期问题和运行时工作,而收益却微乎其微。

为了未来使用,缓存这些引用只花费我们一小部分内存空间(每次只有 32 或 64 位——取决于 Unity 版本、平台和碎片化允许),所以,除非你在内存上极度瓶颈,更好的方法是在初始化时获取引用并保留它们,直到需要它们:

private HealthComponent _healthComponent;
private Rigidbody _rigidbody;
private Collider _collider;
private AIControllerComponent _ai;
private Animator _anim;

void Awake() {
  _healthComponent = GetComponent<HealthComponent>();
  _rigidbody = GetComponent<Rigidbody>();
  _collider = GetComponent<Collider>();
  _ai = GetComponent<AIControllerComponent>();
  _anim = GetComponent<Animator>();
}

void TakeDamage() {
  if (_healthComponent.health < 0) {
    _rigidbody.detectCollisions = false;
    _collider.enabled = false;
    _ai.enabled = false;
    _anim.SetTrigger("death");
  }
}

以这种方式缓存组件引用可以让我们避免每次需要时都重新获取它们,每次都节省一些 CPU 开销。代价是额外的少量内存消耗,这通常是非常值得的。

同样的建议适用于我们决定在运行时计算的任何数据。当我们可以将它存储在内存中以供未来参考时,没有必要让 CPU 在每次Update()回调时都重新计算相同的值。

共享计算输出

通过让多个对象共享某些计算的结果,可以节省性能;当然,这只有在它们都生成相同的结果时才有效。这种情况通常很容易发现,但重构可能很棘手,因此利用这一点将非常依赖于实现。

一些例子可能包括在场景中找到一个对象、从文件中读取数据、解析数据(如 XML 或 JSON)、在一个大列表或信息深度字典中找到某个东西、计算一组人工智能AI)对象的路径、类似复杂数学的轨迹、光线投射等等。

每当进行一次昂贵的操作时,考虑它是否从多个位置调用但总是产生相同的输出。如果是这样,那么重新组织事物以使结果只计算一次,然后将其分发给需要它的每个对象,以最小化重新计算的数量将是明智的。最大的成本通常是代码简单性的一小部分损失,尽管我们可能通过移动值而造成一些额外的开销。

注意,通常很容易养成在基类中隐藏一些大而复杂的函数的习惯,然后我们定义派生类来使用该函数,完全忘记了该函数的成本,因为我们很少再次查看那段代码。最好使用 Unity Profiler 来告诉我们那个昂贵的函数可能被调用多少次,并且像往常一样,除非已经证明它是性能问题,否则不要预先优化这些函数。无论它可能多么昂贵,如果它不会导致我们超过性能限制(如帧率和内存消耗),那么它实际上并不是一个性能问题。

更新、协程和 InvokeRepeating

另一个容易陷入的习惯是在Update()回调中以比所需更频繁的方式重复调用某些东西。例如,我们可能从一个类似这样的情况开始:

void Update() {
  ProcessAI();
}

在这种情况下,我们每帧都调用一些自定义的ProcessAI()子程序。这可能是一项复杂的工作,需要 AI 系统检查一些网格系统以确定它应该移动到哪个位置,或者为一批宇宙飞船或我们游戏 AI 需要的任何东西确定一些舰队机动。

如果这个活动消耗了我们的帧率预算太多,并且任务可以比每帧更少地完成而没有显著的缺点,那么提高性能的一个好方法就是简单地减少调用ProcessAI()的频率:

private float _aiProcessDelay = 0.2f;
private float _timer = 0.0f;

void Update() {
  _timer += Time.deltaTime;
  if (_timer > _aiProcessDelay) {
    ProcessAI();
    _timer -= _aiProcessDelay;
  }
}

在这种情况下,我们通过每秒只调用ProcessAI()大约五次来减少了Update()回调的整体成本,这比之前的情况有所改进,但代价是代码可能需要一点时间才能理解,并且需要额外的内存来存储一些浮点数据——尽管,最终,我们仍然经常让 Unity 调用一个空的回调函数。

这个函数是一个完美的例子,可以将它转换成协程以利用它们的延迟调用特性。如前所述,协程通常用于编写一系列事件,无论是单次还是重复执行的动作。它们不应与线程混淆,线程会在不同的 CPU 核心上并发运行,并且可以同时运行多个线程。相反,协程在主线程上以顺序方式运行,在任何给定时刻只处理一个协程,每个协程通过 yield 语句决定何时暂停和恢复。以下代码示例展示了我们如何将前面的 Update() 回调以协程的形式重写:

void Start() {
  StartCoroutine(ProcessAICoroutine ());
}

IEnumerator ProcessAICoroutine () {
  while (true) {
    ProcessAI();
    yield return new WaitForSeconds(_aiProcessDelay);
  }
}

前面的代码演示了一个协程,它调用 ProcessAI(),然后在 yield 语句上暂停给定的时间(_aiProcessDelay 的值)后,主线程再次恢复协程,此时它将回到循环的开始,调用 ProcessAI(),再次在 yield 语句上暂停,并无限重复(通过 while(true) 语句)直到被要求停止。

这种方法的主要好处是,这个函数将只根据 _aiProcessDelay 的值调用,并且在此期间将处于空闲状态,减少了对大多数帧造成的性能影响。然而,这种方法也有其缺点。

首先,启动协程相对于标准函数调用会带来额外的开销(大约慢三倍),以及一些内存分配来存储当前状态,直到下一次被调用。这种额外的开销也不是一次性成本,因为协程经常不断地调用 yield,这会反复产生相同的开销成本,因此我们需要确保减少频率带来的好处超过这种成本。

在对 1,000 个具有空 Update() 回调的对象进行测试时,处理耗时为 1.1 毫秒,而 1,000 个在 WaitForEndOfFrame 上产生 yield 的协程(其频率与 Update() 回调相同)耗时为 2.9 毫秒。因此,相对成本几乎是三倍。

其次,一旦初始化,协程将独立于触发 MonoBehaviour 组件的 Update() 回调运行,并且无论组件是否被禁用,都会继续被调用,这在我们进行大量的 GameObject 构造和销毁时可能会使它们难以控制。

第三,一旦包含它的 GameObject 实例因任何原因(无论是被设置为非活动状态还是其父对象之一被设置为非活动状态)变得非活动,协程将自动停止,并且如果 GameObject 再次设置为活动状态,它不会自动重新启动。

最后,通过将方法转换为协程,我们可能已经减少了在大多数帧上造成的性能损失,但如果方法体的单个调用导致我们超出帧率预算,那么无论我们调用该方法的频率有多低,它仍然会超出预算。因此,这种方法最好用于我们只是因为方法在给定帧中被调用的次数过多而打破帧率预算的情况,而不是因为方法本身成本过高。在这种情况下,我们别无选择,只能深入挖掘并提高方法本身的性能,或者减少其他任务的成本,以便为完成其工作腾出时间。

在生成协程时,我们有几种 yield 类型可供选择。WaitForSeconds 是相当直观的;协程将在 yield 语句暂停给定的时间数秒。然而,它并不是一个精确的计时器,因此当这个 yield 类型实际恢复时,请预期会有一些变化。

WaitForSecondsRealTime 是另一个选项,它与 WaitForSeconds 的区别仅在于它使用未缩放的时间。WaitForSeconds 是与缩放时间进行比较,这会受到全局 Time.timeScale 属性的影响,而 WaitForSecondsRealTime 则不会,因此在调整时间缩放值(例如,用于慢动作效果)时,请注意您使用的 yield 类型。

此外,还有 WaitForEndOfFrame,它将在下一个 Update() 回调结束时继续执行,然后是 WaitForFixedUpdate,它将在下一个 FixedUpdate() 调用结束时继续执行。最后,Unity 5.3 引入了 WaitUntilWaitWhile,我们在这里提供委托函数,协程将暂停,直到提供的委托返回 truefalse。请注意,提供给这些 yield 类型的委托将在每次 Update() 调用中执行,直到它们返回所需的布尔值以停止执行,这使得它们与使用 WaitForEndOfFramewhile 循环中结束的协程非常相似。当然,我们提供的委托函数执行成本不高也很重要。

委托函数是 C# 中非常实用的结构,它允许我们将局部方法作为参数传递给其他方法,并且通常用于回调。有关委托的更多信息,请参阅 MSDN 的 C# 编程指南,链接为 docs.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/

一些Update()回调的编写方式可能可以被简化为简单的协程,这些协程始终在这些类型之一上调用yield,但我们应意识到之前提到的缺点。协程调试可能很棘手,因为它们不遵循正常的执行流程;在调用栈中没有直接的调用者可以责怪为什么协程在特定时间被触发,并且如果协程执行复杂任务并与其他子系统交互,那么它们可能导致一些难以置信的困难错误,因为这些错误发生在其他代码没有预料到的时间点,这些错误通常也很难重现。如果你确实希望使用协程,最好的建议是保持它们简单,并且与其他复杂的子系统独立。

事实上,如果我们的协程足够简单,可以简化为一个始终在WaitForSecondsWaitForSecondsRealtime上调用yieldwhile循环,就像前面的例子一样,那么我们通常可以用一个InvokeRepeating()调用替换它,这甚至更容易设置,并且有稍微低的开销成本。以下代码在功能上与之前使用协程定期调用ProcessAI()方法的实现等效:

void Start() {
  InvokeRepeating("ProcessAI", 0f, _aiProcessDelay);
}

InvokeRepeating()和协程之间的重要区别在于InvokeRepeating()完全独立于MonoBehaviourGameObject的状态。停止InvokeRepeating()调用的唯一两种方式是调用CancelInvoke(),这将停止由给定MonoBehaviour启动的所有InvokeRepeating()回调(注意,它们不能单独取消)或者销毁相关的MonoBehaviour或其父GameObject。禁用MonoBehaviourGameObject都不会停止InvokeRepeating()

对 1,000 次InvokeRepeating()调用的测试处理大约需要 2.6 毫秒;这比 1,000 次等效的协程yield调用快一点,后者耗时 2.9 毫秒。

这涵盖了与Update()回调相关的多数有用信息。让我们来看看其他有用的脚本提示。

更快的 GameObject 空引用检查

结果表明,对GameObject执行空引用检查将导致一些不必要的性能开销。与典型的 C#对象相比,GameObject 和 MonoBehaviours 是特殊对象,因为它们在内存中有两种表示:一个存在于管理我们编写的 C#代码的同一系统的内存中(托管代码),而另一个存在于不同的内存空间中,该空间被单独处理(原生代码)。数据可以在这两个内存空间之间移动,但每次发生这种情况都会导致一些额外的 CPU 开销,并可能需要额外的内存分配。

这种效果通常被称为跨越原生-托管桥接。如果发生这种情况,它可能会为对象的数据在桥接过程中生成额外的内存分配,这将需要垃圾回收器最终为我们执行一些自动内存清理。这个主题将在第八章精通内存管理中详细探讨,但在此期间,只需考虑有许多微妙的方式可以意外触发这种额外的开销,而检查GameObject的简单null引用就是其中之一:

if (gameObject != null) {
  // do stuff with gameObject
}

一种生成功能等效输出且操作速度快两倍的方法(尽管它稍微模糊了代码的目的)是System.Object.ReferenceEquals()

if (!System.Object.ReferenceEquals(gameObject, null)) {
  // do stuff with gameObject
}

这适用于 GameObject 和 MonoBehaviours,以及具有原生和托管表示的其他 Unity 对象,例如WWW类。然而,一些基本的测试表明,无论是哪种null引用检查方法,在 Intel Core i5 3570K 处理器上仍然只消耗微秒级的纳秒。所以,除非你正在执行大量的null引用检查,否则收益可能微乎其微。然而,这是一个值得记住的未来警告,因为它会经常出现。

避免从 GameObject 中检索字符串属性

通常,从对象中检索string属性与检索 C#中的任何其他引用类型属性相同;它应该没有额外的内存成本。然而,从 GameObject 中检索string属性是另一种意外跨越原生-托管桥接的微妙方式。

受此行为影响的GameObject的两个属性是tagname。因此,在游戏过程中使用这两个属性是不明智的,你应该只在性能无关的区域使用它们,例如编辑器脚本。然而,标签系统通常用于对象的运行时识别,这可能会给某些团队带来重大问题。

例如,以下代码会在循环的每次迭代中造成额外的内存分配:

for (int i = 0; i < listOfObjects.Count; ++i) {
  if (listOfObjects[i].tag == "Player") {
    // do something with this object
  }
}

通常,通过识别对象的组件和类类型来识别对象,以及识别不涉及string对象的值,是一种更好的做法。但有时我们被迫陷入困境。也许我们在开始时并不了解更好的方法,我们继承了别人的代码库,或者我们正在用它作为某种问题的解决方案。让我们假设,无论出于什么原因,我们都陷入了标签系统,并且我们希望避免原生-托管桥接的开销。

幸运的是,tag属性通常用于比较场景,GameObject提供了CompareTag()方法,这是一种比较tag属性的方法,可以完全避免原生-托管桥接。

让我们进行一个简单的测试来证明这个简单的改变可以带来多大的差异:

void Update() {

  int numTests = 10000000;

  if (Input.GetKeyDown(KeyCode.Alpha1)) {
    for(int i = 0; i < numTests; ++i) {
      if (gameObject.tag == "Player") {
        // do stuff
      }
    }
  }

  if (Input.GetKeyDown(KeyCode.Alpha2)) {
    for(int i = 0; i < numTests; ++i) {
      if (gameObject.CompareTag ("Player")) {
        // do stuff
      }
    }
  }
}

我们可以通过按下 12 键来触发相应的 for 循环来执行这些测试。以下是结果:

查看每个峰值的分析视图,我们可以看到两种完全不同的结果:

值得注意的是,时间轴视图中的两个峰值看起来高度相对相同,但一个操作却比另一个操作耗时多一倍。当超过 15FPS 标记时,Profiler 没有足够的垂直分辨率来生成相对准确的峰值。无论如何,这两种情况都会导致糟糕的游戏体验,所以准确性并不重要。

重复获取 tag 属性 1000 万次(在现实中远远超过合理的次数,但这对比较很有用)仅导致大约 400 兆字节的内存被分配,仅用于 string 对象。我们可以在时间轴视图的内存区域中看到 GC Allocated 元素中的峰值,这个过程大约需要 2,000 毫秒来处理,一旦 string 对象不再需要,垃圾回收会花费另外 400 毫秒。

同时,使用 CompareTag() 10 万次大约需要 1,000 毫秒来处理,并且不会引起内存分配,因此也不会有垃圾回收。这一点可以从内存区域中 GC Allocated 元素没有峰值中明显看出。这应该清楚地表明,我们应尽可能避免访问 nametag 属性。如果 tag 比较成为必要,那么我们应该使用 CompareTag()。不幸的是,没有 name 属性的等效函数,因此我们应该尽可能使用标签。

注意,将 string 文字字面量,如 "Player",传递给 CompareTag() 不会导致运行时内存分配,因为应用程序在初始化期间分配硬编码的字符串,并在运行时仅引用它们。

使用合适的数据结构

C# 在 System.Collections 命名空间中提供了许多不同的数据结构,我们不应该过于习惯于反复使用相同的结构。软件开发中常见的性能问题之一是,由于方便而使用不合适的数据结构来解决我们试图解决的问题。最常用的可能是列表 (List<T>) 和字典 (Dictionary<K,V>)。

如果我们想要遍历一组对象,那么列表是首选的,因为它实际上是一个动态数组,其中对象和/或引用在内存中相邻,因此迭代造成的缓存未命中最小。当两个对象相互关联,并且我们希望快速获取、插入或删除这些关联时,字典是最佳选择。例如,我们可能会将一个层级编号与特定的场景文件关联起来,或者用一个表示角色不同身体部位的enum与这些身体部位的Collider组件关联起来。

然而,我们通常希望有一个可以处理这两种情况的数据结构;我们希望快速找出哪个对象映射到另一个对象,同时还能遍历该组。通常,这个系统的开发者会使用一个字典,然后遍历它。然而,与遍历列表相比,这个过程非常慢,因为它必须检查字典中的每个潜在哈希值才能完全遍历它。

在这些情况下,通常更好的做法是在列表和字典中同时存储数据,以更好地支持这种行为。这将为维护多个数据结构带来额外的内存开销,并且插入和删除操作需要每次从这两个数据结构中添加和删除对象,但列表迭代的优点(这通常发生得更多)与遍历字典相比将形成鲜明的对比。

避免在运行时重新分配变换的父子关系

在 Unity 的早期版本(5.3 及更早版本)中,Transform组件的引用在内存中的布局通常是随机的。这意味着遍历多个Transform组件相当慢,因为缓存未命中的可能性很高。好处是,将GameObject重新分配给另一个对象不会真正造成显著的性能损失,因为Transforms操作类似于堆数据结构,通常在插入和删除方面相对较快。这种行为是我们无法控制的,所以我们只能忍受它。

然而,自从 Unity 5.4 版本以来,Transform组件的内存布局发生了显著变化。从那时起,Transform组件的父子关系更像是动态数组,Unity 试图在预分配的内存缓冲区中按顺序存储所有与同一父对象共享的Transform,并在父对象下方的层次结构窗口中按深度排序。这种数据结构允许在整个组上快速迭代,这对于多个子系统(如物理和动画)特别有益。

这种变化的缺点是,如果我们将 GameObject 重新设置为另一个对象的子对象,父对象必须在其预分配的内存缓冲区中适应新的子对象,并且根据新的深度对所有这些 Transforms 进行排序。此外,如果父对象没有预分配足够的空间来适应新的子对象,那么它必须扩展其缓冲区以能够适应新的子对象及其所有子对象,按照深度优先的顺序。对于深度和复杂的 GameObject 结构,这可能需要一些时间才能完成。

当我们通过 GameObject.Instantiate() 实例化一个新的 GameObject 时,其中一个参数是我们希望将 GameObject 设置为其父对象的 Transform 组件,默认情况下为 null,这将 Transform 放置在 Hierarchy 窗口的根位置。Hierarchy 窗口根位置的所有 Transforms 都需要分配一个缓冲区来存储其当前子对象以及以后可能添加的子对象(子 Transforms 不需要这样做)。但是,如果我们立即在实例化后重新将 Transform 设置为另一个对象,那么它会丢弃我们刚刚分配的缓冲区!为了避免这种情况,我们应该在 GameObject.Instantiate() 调用中提供父 Transform 参数,这将跳过此缓冲区分配步骤。

另一种减少此过程成本的方法是在我们需要之前预先为根 Transform 分配更大的缓冲区,这样我们就不需要在同一帧中同时扩展和重新将另一个 GameObject 实例放入缓冲区。这可以通过修改 Transform 组件的 hierarchyCapacity 属性来实现。如果我们能够估计父对象将包含的子 Transforms 的数量,那么我们可以节省大量的不必要的内存分配。

考虑缓存变换更改

Transform 组件仅存储相对于其自身父对象的数据。这意味着访问和修改 Transform 组件的 positionrotation 和/或 scale 属性可能会引发大量未预料的矩阵乘法计算,以生成通过父 Transforms 的正确 Transform 表示。对象在 Hierarchy 窗口中的深度越深,所需的计算就越多,以确定最终结果。

然而,这也意味着使用 localPositionlocalRotationlocalScale 与之相关的成本相对较小,因为这些值直接存储在给定的 Transform 组件中,并且可以在没有任何额外的矩阵乘法的情况下检索。因此,应尽可能使用这些局部属性值。

不幸的是,将我们的数学计算从世界空间转换为局部空间可能会使原本简单(且已解决)的问题变得过于复杂,因此进行此类更改的风险是破坏我们的实现并引入大量意外的错误。有时,为了更容易地解决复杂的 3D 数学问题,值得承受轻微的性能损失。

持续更改 Transform 组件的属性还存在另一个问题,即它还会向 ColliderRigidbodyLightCamera 等组件发送内部通知,这些组件也必须被处理,因为物理和渲染系统都需要知道新的 Transform 值并相应地更新。

在复杂的事件链中,我们有时会在同一帧内多次替换 Transform 组件的属性(尽管这可能是过度设计的一个警告信号)。这会导致每次发生这种情况时都会触发内部消息,即使它们发生在同一帧或同一函数调用中。因此,我们应该考虑通过在成员变量中缓存它们并在帧末尾提交来最小化修改 Transform 属性的次数,如下所示:

private bool _positionChanged;
private Vector3 _newPosition;

public void SetPosition(Vector3 position) {
  _newPosition = position;
  _positionChanged = true;
}

void FixedUpdate() {
  if (_positionChanged) {
    transform.position = _newPosition;
    _positionChanged = false;
  }
}

此代码只会在下一个 FixedUpdate() 方法中提交对 position 的更改。

注意,以这种方式更改 Transform 组件不会在游戏过程中导致看起来奇怪的行为或物体瞬移。这些内部事件的整体目的是确保物理和渲染系统始终与当前的 Transform 状态同步。因此,Unity 不会错过任何一次,每当通过 Transform 组件传递更改时,都会触发内部事件,以确保不会错过任何东西。

避免在运行时使用 Find() 和 SendMessage()

SendMessage() 方法及其 GameObject.Find() 方法族因其高昂的成本而臭名昭著,应尽量避免使用。SendMessage() 方法的速度大约是简单函数调用的 2,000 倍慢,而 Find() 方法的成本随着场景复杂性的增加而非常差,因为它必须遍历场景中的每一个 GameObject。在某些情况下,例如在场景初始化期间,如 Awake()Start() 回调中调用 Find() 可能是情有可原的。即使在这种情况下,也仅应使用它来获取我们确信已经存在于场景中的对象,以及那些只有少数 GameObject 的场景。无论如何,在运行时使用这些方法进行对象间通信很可能会产生非常明显的开销,甚至可能导致帧率下降。

依赖于Find()SendMessage()通常是设计不佳、C#和 Unity 编程经验不足或原型设计时的懒惰的典型症状。它们的用法在初级和中级项目中已经成为一种流行病,以至于 Unity Technologies 觉得有必要在他们的文档和会议上反复提醒用户,避免在实际游戏中反复使用它们。它们只是一种不那么“程序员风格”的方式来向新用户介绍对象间通信,以及在少数特殊情况下可以负责任地使用(这些情况很少见)。换句话说,它们过于昂贵,以至于违反了不预先优化代码的规则,如果我们的项目超出了原型设计阶段(由于你正在阅读这本书,这完全有可能),那么避免使用它们是值得的。

公平地说,Unity 的目标用户群体非常广泛,从爱好者到学生和专业人员,再到个人开发者,以及同一个团队中的数百人。这导致软件开发能力范围极其广泛。当你刚开始使用 Unity 时,自己很难弄清楚应该做哪些不同的事情,特别是考虑到 Unity 引擎并不遵循我们可能熟悉的许多其他游戏引擎的设计范式。它有一些与场景和 Prefab 相关的陌生和古怪的概念,并且没有内置的God类入口,也没有明显的原始数据存储系统可供使用。

God类是一个对我们应用中可能创建的第一个对象的别称,其职责是根据当前上下文(例如加载哪个级别,激活哪些子系统等)创建我们需要的所有其他东西。这些类在需要单个集中位置来控制事件在整个应用生命周期中发生顺序的情况下特别有用。

了解如何在复杂的软件架构组件之间交换消息不仅对 Unity 的性能有用,而且对任何实时事件驱动系统(包括但不限于游戏)的设计也很有用,因此详细探讨这个主题,评估一些替代对象间通信的方法是值得的。

让我们先考察一个最坏情况的例子,它同时使用Find()SendMessage()在对象之间进行通信,然后探讨改进的方法。

以下是一个简单的EnemyManagerComponent实例的类定义,它跟踪表示游戏中敌人的 GameObject 列表,并提供一个KillAll()方法,在需要时销毁它们:

using UnityEngine;
using System.Collections.Generic;

class EnemyManagerComponent : MonoBehaviour {
  List<GameObject> _enemies = new List<GameObject>();

  public void AddEnemy(GameObject enemy) {
    if (!_enemies.Contains(enemy)) {
      _enemies.Add(enemy);
    }
  }

  public void KillAll() {
    for (int i = 0; i < _enemies.Count; ++i) {
      GameObject.Destroy(_enemies[i]);
    }
    _enemies.Clear();
  }
}

然后,我们将一个包含此组件的GameObject实例放置到场景中,并将其命名为EnemyManager

以下示例方法尝试从给定的 Prefab 实例化几个敌人,然后通知EnemyManager对象它们的存在:

public void CreateEnemies(int numEnemies) {
  for(int i = 0; i < numEnemies; ++i) {
    GameObject enemy = (GameObject)GameObject.Instantiate(_enemyPrefab, 
                       5.0f * Random.insideUnitSphere, 
                       Quaternion.identity);
    string[] names = { "Tom", "Dick", "Harry" };
    enemy.name = names[Random.Range(0, names.Length)];
    GameObject enemyManagerObj = GameObject.Find("EnemyManager");
    enemyManagerObj.SendMessage("AddEnemy", 
                                enemy, 
                                SendMessageOptions.DontRequireReceiver);
  }
}

在任何类型的循环中初始化数据和将方法调用放入其中,这总是输出相同的结果,是性能不佳的一个大红旗,当我们处理像Find()这样的昂贵方法时,我们应该总是寻找尽可能少调用它们的方法。因此,我们可以做出的一个改进是将Find()调用移出for循环,并将结果缓存到局部变量中,这样我们就不需要一次又一次地重新获取EnemyManager对象。

names变量的初始化移出for循环并不一定是关键的,因为编译器通常足够聪明,能够意识到它不需要重新初始化那些在其他地方没有改变的数据。然而,这通常会使代码更容易阅读。

我们可以实施的另一个重大改进是优化我们对SendMessage()方法的用法,通过将其替换为GetComponent()调用。这用一个成本高昂的方法替换了一个等效且成本更低的替代方案。

这给我们带来了以下结果:

public void CreateEnemies(int numEnemies) {
  GameObject enemyManagerObj = GameObject.Find("EnemyManager");
  EnemyManagerComponent enemyMgr = enemyManagerObj.GetComponent<EnemyManagerComponent>();
  string[] names = { "Tom", "Dick", "Harry" };

  for(int i = 0; i < numEnemies; ++i) {
    GameObject enemy = (GameObject)GameObject.Instantiate(_enemyPrefab, 
                        5.0f * Random.insideUnitSphere, 
                        Quaternion.identity);
    enemy.name = names[Random.Range(0, names.Length)];
    enemyMgr.AddEnemy(enemy);
  }
}

如果这个方法在场景初始化期间被调用,并且我们不太关心加载时间,那么我们可能可以认为我们的优化工作已经完成了。

然而,我们经常需要新的对象,这些对象在运行时实例化以找到现有对象进行通信。在这个例子中,我们希望新的敌人对象注册到我们的EnemyManagerComponent,以便它可以执行跟踪和控制场景中敌人对象所需的一切。我们还希望EnemyManager处理所有与敌人相关的行为,这样调用其函数的对象就不需要代表它执行工作。这将提高我们应用程序的耦合度(我们的代码库如何分离相关行为)和封装性(我们的类如何防止外部对其管理的数据进行更改)。最终目标是找到一种可靠且快速的方法,让新对象在场景中找到现有对象,而不必使用Find()方法,这样我们就可以最小化复杂性和性能成本。

我们可以采取多种方法来解决此问题,每种方法都有其自身的优点和缺点:

  • 将引用分配给现有对象

  • 静态类

  • 单例组件

  • 一个全局消息系统

将引用分配给现有对象

解决对象间通信问题的简单方法之一是使用 Unity 内置的序列化系统。软件设计纯粹主义者可能会对这个特性有些抵触,因为它打破了封装性;它使得标记为private的字段表现得像public字段一样。然而,它是一个非常有效的工具,可以改善开发工作流程。这在艺术家、设计师和程序员都在同一个产品上摸索,每个人的计算机科学和软件开发知识水平差异很大,有些人可能更愿意远离修改代码文件的情况下尤其如此。有时,为了提高生产力,稍微放宽一些规则是值得的。

无论何时我们在MonoBehaviour中创建一个public字段,当组件被选中时,Unity 都会自动序列化并在检查器窗口中暴露其值。然而,从软件设计的角度来看,public字段总是危险的。这些变量可以从任何地方通过代码随时更改,这使得跟踪变量变得困难,并且容易引入许多意外的错误。

一个更好的解决方案是将类的任何privateprotected成员变量暴露给检查器窗口,使用[SerializeField]属性。然后,该值将像public字段一样在检查器窗口中表现,允许我们通过编辑器界面方便地更改它,但将数据安全地封装在我们的代码库的其他部分。

例如,以下类将三个private字段暴露给检查器窗口:

using UnityEngine;

public class EnemyCreatorComponent : MonoBehaviour {
  [SerializeField] private int _numEnemies;
  [SerializeField] private GameObject _enemyPrefab;
  [SerializeField] private EnemyManagerComponent _enemyManager;

  void Start() {
    for (int i = 0; i < _numEnemies; ++i) {
      CreateEnemy();
    }
  }

  public void CreateEnemy() {
    _enemyManager.CreateEnemy(_enemyPrefab); 
  }
}

注意,前面代码中显示的private访问修饰符在 C#中是多余的,因为字段和方法默认为private,除非指定了其他情况。然而,通常最好明确指定预期的访问级别。

在检查器窗口中查看此组件会显示三个值,最初给定默认值0null,可以通过编辑器界面进行修改:

图片

我们可以将从项目窗口拖放的一个 Prefab 引用拖放到检查器窗口中显示的 Enemy Prefab 字段。

注意 Unity 如何自动将驼峰式命名的字段名称转换为方便的检查器窗口名称。_numEnemies变为 Num Enemies,_enemyPrefab变为 Enemy Prefab,依此类推。

同时,_enemyManager字段很有趣,因为它是对特定MonoBehaviour类类型的引用。如果将GameObject拖放到这个字段中,那么它将引用给定对象上的组件,而不是GameObject本身。请注意,如果GameObject不包含预期的MonoBehaviour实例,则该字段将不会被分配任何内容。

这种组件引用技术的常见用法是获取附加到与组件相同的 GameObject 上的其他组件的引用。这是缓存组件的一种零成本替代方法,正如本章前面标题为“缓存组件引用”的部分所讨论的。

使用这种方法存在一些风险。我们的大部分代码都会假设 Prefab 被分配到一个用作 Prefab 的字段,而 GameObject 被分配到一个引用 GameObject 实例的字段。然而,由于 Prefab 本质上是 GameObject,任何 Prefab 或 GameObject 都可以分配到序列化的 GameObject 引用字段,这意味着我们可能会意外地分配错误的类型。

如果我们错误地分配了类型,那么我们可能会意外地从之前修改过的现有 GameObject 实例中实例化一个新的 GameObject 实例,或者我们可能会修改 Prefab,这将随后改变从它实例化的所有 GameObject 的状态。更糟糕的是,由于 Prefab 无论 Playmode 是否激活都占用相同的内存空间,任何对 Prefab 的意外更改都会变成永久性的。即使 Prefab 只在 Playmode 期间被修改,也是如此。

因此,这种方法是解决对象间通信问题的一种非常团队友好的方法,但由于所有涉及的风险,它并不理想,因为团队成员可能会意外地留下 null 引用,将 Prefab 分配到期望从场景中获取 GameObject 实例的引用,反之亦然。

还需要注意的是,并非所有对象都可以序列化并在检查器窗口中显示。Unity 可以序列化所有原始数据类型(intfloatstringbool)、各种内置类型(Vector3Quaternion 等)、enumclassstruct 以及包含其他可序列化类型(如 List)的各种数据结构。然而,它无法序列化 static 字段、readonly 字段、属性和字典。

一些 Unity 开发者喜欢通过两个单独的列表来实现字典的伪序列化,一个用于键,一个用于值,以及一个自定义编辑器脚本,或者通过一个包含键和值的 struct 对象的单列表。这两种解决方案都有些笨拙,并且通常不如正确的字典那样高效,但它们仍然可能很有用。

解决对象间通信问题的另一种方法是尝试使用全局可访问的对象来最小化我们需要进行的自定义赋值数量。

静态类

这种方法涉及创建一个类,该类可以在任何时候被整个代码库全局访问。在软件工程领域,任何类型的全局管理类通常都不受欢迎,部分原因是名称“管理器”含糊不清,并没有说明它打算做什么,但主要原因是问题可能难以调试。更改可能发生在运行时任何地方和任何时刻,并且这类类倾向于维护其他系统所依赖的状态信息。此外,这可能是最难更改或替换的方法,因为我们的许多类可能直接调用它,如果需要替换,则每个类都需要在未来某个日期进行修改。尽管存在所有这些缺点,但这是最容易理解和实现的方法。

单例设计模式是确保在内存中始终只有一个特定对象类型的实例的常见方法。这种设计模式通过给类提供一个private构造函数来实现,维护一个static变量以跟踪对象实例,并且只能通过它提供的static属性来访问该类。单例对于管理共享资源或大量数据流量(如文件访问、下载、数据解析和消息传递)非常有用。单例确保我们有一个此类活动的单一入口点,而不是有大量不同的子系统争夺共享资源,并可能相互阻塞。

单例不一定是全局可访问的对象——它们最重要的特性是在任何时候只有一个对象实例存在。然而,单例在大多数项目中主要被用作对某些共享功能的全局访问点,并且它们被设计为在应用程序初始化期间创建一次,在整个应用程序生命周期中持续存在,并且仅在应用程序关闭时被销毁。因此,在 C#中实现这种行为的简单方法就是使用静态类。换句话说,在 C#中实现典型的单例设计模式只是提供了与静态类相同的行为,但需要更多的时间和代码来实现。

一个静态类,其功能与EnemyManagerComponent在先前的示例中展示的方式几乎相同,可以定义为如下:

using System.Collections.Generic;
using UnityEngine;

public static class StaticEnemyManager {
  private static List<Enemy> _enemies;

  public static void CreateEnemy(GameObject prefab) {
    string[] names = { "Tom", "Dick", "Harry" };
    GameObject enemy = GameObject.Instantiate(prefab, 5.0f * 
    Random.insideUnitSphere, Quaternion.identity);
    Enemy enemyComp = enemy.GetComponent<Enemy>();
    enemy.gameObject.name = names[Random.Range(0, names.Length)];
    _enemies.Add(enemyComp);
  }

  public static void KillAll() {
    for (int i = 0; i < _enemies.Count; ++i) {
      _enemies[i].Die();
      GameObject.Destroy(_enemies[i].gameObject);
    }
    _enemies.Clear();
  }
}

注意,静态类中的每个方法、属性和字段都必须附加static关键字,这意味着内存中将始终只有一个此类对象的实例。这也意味着其public方法和字段可以从任何地方访问。根据定义,静态类不允许定义任何非static字段。

如果静态类字段需要初始化(例如,_enemies字段最初设置为null),则静态类字段可以像这样内联初始化:

private static List<Enemy> _enemies = new List<Enemy>();

然而,如果对象构造比这更复杂,则可以给静态类提供一个static构造函数。静态类构造函数在第一次通过其任何字段、属性或方法访问类时自动调用,可以定义如下:

static StaticEnemyManager() {
  _enemies = new List<Enemy>();
  // more complicated initialization activity goes here
}

这次,我们实现了CreateEnemy()方法,以便它处理创建敌人对象的大部分活动。然而,静态类仍然需要提供一个从其中实例化敌人对象的 Prefab 的引用。静态类只能包含static成员变量,因此不能像 MonoBehaviours 那样轻松地与检查器窗口接口,因此需要调用者提供一些特定实现的详细信息。为了解决这个问题,我们可以为我们的静态类实现一个伴随组件,以使我们的代码正确地解耦。以下代码演示了这个类可能的样子:

using UnityEngine;

public class EnemyCreatorCompanionComponent : MonoBehaviour {
  [SerializeField] private GameObject _enemyPrefab;

  public void CreateEnemy() {
    StaticEnemyManager.CreateEnemy(_enemyPrefab);
  }
}

尽管有这些缺点,StaticEnemyManager类展示了如何使用静态类提供外部对象之间信息或通信的简单示例,这比使用Find()SendMessage()提供了更好的替代方案。

单例组件

如前所述,静态类在接口 Unity 相关功能方面有困难,并且不能直接使用MonoBehaviour功能,如事件回调、协程、层次设计和 Prefab。此外,由于检查器窗口中没有对象可以选择,我们失去了在运行时通过检查器窗口检查静态类数据的能力,这可能会使调试变得困难。这些是我们可能希望在我们的全局类中使用的功能。

解决这个问题的常见方法是实现一个充当单例的组件——它提供static方法以提供全局访问,并且在任何给定时间只允许存在一个MonoBehaviour实例。

以下是对SingletonComponent类的定义:

using UnityEngine;

public class SingletonComponent<T> : MonoBehaviour where T : SingletonComponent<T> {
  private static T __Instance;

  protected static SingletonComponent<T> _Instance {
    get {
      if(!__Instance) {
        T[] managers = GameObject.FindObjectsOfType(typeof(T)) as T[];
        if (managers != null) {
          if (managers.Length == 1) {
            __Instance = managers[0];
            return __Instance;
          } else if (managers.Length > 1) {
            Debug.LogError("You have more than one " + 
                            typeof(T).Name + 
                            " in the Scene. You only need " + 
                            "one - it's a singleton!");
            for(int i = 0; i < managers.Length; ++i) {
              T manager = managers[i];
              Destroy(manager.gameObject);
            }
          }
        }
        GameObject go = new GameObject(typeof(T).Name, typeof(T));
        __Instance = go.GetComponent<T>();
        DontDestroyOnLoad(__Instance.gameObject);
      }
      return __Instance;
    }
    set {
      __Instance = value as T;
    }
  }
}

这个类通过在第一次访问时创建包含其自身组件的GameObject来工作。由于我们希望这是一个全局且持久化的对象,我们将在创建GameObject后不久调用DontDestroyOnLoad()。这是一个特殊函数,告诉 Unity 我们希望对象在应用程序运行期间在场景之间持久化。从那时起,当加载新场景时,该对象将不会被销毁并保留其所有数据。

这个类定义假设了两件事。首先,因为它使用泛型来定义其行为,我们必须从中派生出一个具体类。其次,必须定义一个方法来分配_Instance属性(这反过来又设置了私有的__Instance字段)并将其转换为正确的类类型。

例如,以下是需要成功生成一个名为 EnemyManagerSingletonComponentSingletonComponent 派生类的最小代码量:

public class EnemyManagerSingletonComponent : SingletonComponent< EnemyManagerSingletonComponent > {
  public static EnemyManagerSingletonComponent Instance {
    get { return ((EnemyManagerSingletonComponent)_Instance); }
    set { _Instance = value; }
  }

  public void CreateEnemy(GameObject prefab) {
    // same as StaticEnemyManager
  }

  public void KillAll() {
    // same as StaticEnemyManager
  }
}

这个类可以在运行时通过任何其他对象在任何时候访问 Instance 属性来使用。如果该组件在我们的场景中尚未存在,则 SingletonComponent 基类将实例化自己的 GameObject 并将其派生类的实例作为组件附加到它上。从那时起,通过 Instance 属性的访问将引用创建的组件,并且同一时间只有一个该组件的实例存在。

注意,这意味着我们不需要在单例组件类定义中实现 static 方法。例如,我们可以简单地调用 EnemyManagerSingletonComponent.Instance.KillAll() 来访问 KillAll() 方法。

注意,由于 SingletonComponent 继承自 MonoBehaviour,因此可以在层次结构窗口中放置 SingletonComponent 的实例。但是,警告,DontDestroyOnLoad() 方法永远不会被调用,这会阻止单例组件的 GameObject 在加载下一个场景时持续存在。我们可能需要在派生类的 Awake() 回调中调用 DontDestroyOnLoad() 来使这生效,除非,当然,我们实际上想要可破坏的单例。有时,允许这样的单例在场景之间被销毁是有意义的,这样它就可以每次都从头开始;这完全取决于我们的特定用例。

在任何情况下,由于 Unity 如何拆解场景,单例组件的关闭可能会有些复杂。对象的 OnDestroy() 回调会在运行时对象被销毁时被调用。在应用程序关闭期间也会调用相同的方法,其中每个 GameObject 上的每个组件的 OnDestroy() 回调都会被 Unity 调用。在编辑器中结束 Playmode 时也会发生同样的活动,因此返回到编辑模式。然而,对象的销毁是随机发生的,我们不能假设 SingletonComponent 对象将是最后一个被销毁的对象。

因此,如果任何对象在它们的 OnDestroy() 回调期间尝试对单例组件进行任何操作,那么它们可能正在调用 SingletonComponent 对象的 Instance 属性。然而,如果单例组件在此之前已经被销毁,那么在应用程序关闭过程中将创建一个新的 SingletonComponent 实例。这可能会损坏我们的场景文件,因为我们的单例组件的实例将留在场景中。如果发生这种情况,那么 Unity 将抛出以下错误信息:

"在关闭场景时,一些对象没有被清理。(你是在 OnDestroy 中创建新的 GameObject 吗?)"

显然,解决方案是简单地在任何 MonoBehaviour 组件的 OnDestroy() 回调中不调用 SingletonComponent 对象。然而,我们可能有一些合法的理由想要这样做:最显著的是,单例通常被设计成利用观察者设计模式。这种设计模式允许其他对象注册/注销以执行特定任务,类似于 Unity 如何捕获回调方法,例如 Start()Update(),但以一种更严格的方式。

使用观察者设计模式,对象通常在创建时会向系统注册,在运行时会使用它,然后在完成使用后或在它们自己的关闭过程中注销,以便进行清理。我们将在下一节中看到一个设计模式的例子,即全局消息系统,但如果想象 MonoBehaviour 使用这样一个系统,那么执行关闭注销的最方便地方可能就是 OnDestroy() 回调中。因此,这样的对象很可能会遇到上述问题,即在应用程序关闭期间意外创建 SingletonComponent 的新 GameObject 实例。

为了解决这个问题,我们需要进行三项更改。首先,我们需要向 SingletonComponent 添加一个额外的标志,该标志跟踪其活动状态并在适当的时候禁用它。这包括单例自己的销毁,以及应用程序关闭(OnApplicationQuit() 是另一个有用的 Unity 回调,它在此时被调用):

private bool _alive = true;
void OnDestroy() { _alive = false; }
void OnApplicationQuit() { _alive = false; }

其次,我们应该实现一种方法,让外部对象验证单例的当前状态:

public static bool IsAlive {
  get {
    if (__Instance == null)
      return false;
    return __Instance._alive;
  }
}

最后,任何尝试在其自己的 OnDestroy() 方法中调用单例的对象,在调用 Instance 之前必须首先使用 IsAlive 属性验证状态,如下所示:

public class SomeComponent : MonoBehaviour {
  void OnDestroy() {
    if (MySingletonComponent.IsAlive) {
        MySingletonComponent.Instance.SomeMethod();
    }
  }
}

这将确保在销毁过程中没有人尝试访问单例实例。如果我们不遵循这个规则,那么我们可能会遇到问题,即我们的单例对象实例在返回到编辑模式后会被遗留在场景中。

SingletonComponent 方法的讽刺之处在于,我们在尝试分配 __Instance 引用变量之前,使用 Find() 调用来确定这些 SingletonComponent 对象中是否已经有一个存在于场景中。幸运的是,这只会发生在首次访问单例组件时,通常情况下,如果场景中游戏对象不多,这不会成为问题,但单例组件的初始化可能并不一定发生在场景初始化期间,因此可能会在游戏进行中,当首次获取实例并调用 Find() 时,给我们带来性能峰值。解决这个问题的方法是让某个 God 类通过简单地访问每个实例的 Instance 属性来确认重要的单例在场景初始化期间已经实例化。

这种方法的另一个缺点是,如果我们后来决定我们希望同时有多个这些单例执行,或者我们希望将其行为分离出来以使其更模块化,那么将需要更改大量的代码。

我们将要探索的最终方法将尝试解决之前解决方案中揭示的许多问题,并通过结合易于实现、易于扩展和严格的用法来提供一种方法,这也有助于在配置过程中减少人为错误的可能性。

全局消息系统

解决对象间通信问题的最终建议方法是实现一个全局消息系统,任何对象都可以访问并通过它向任何可能对特定类型消息感兴趣的对象发送消息。对象可以发送消息或监听消息(有时两者都是!),责任在于监听器决定他们感兴趣的消息。消息发送者可以广播消息而不关心谁在监听,并且无论消息的具体内容如何,都可以通过系统发送消息。这种方法无疑是迄今为止最复杂的,可能需要一些努力来实现和维护,但它是一个出色的长期解决方案,可以保持我们的对象通信模块化、解耦和快速,随着我们的应用程序变得越来越复杂。

我们希望发送的消息可以采取多种形式,包括数据值、引用、对监听器的指令等,但它们都应该有一个共同的、基本定义,我们的消息系统可以使用它来确定消息的内容以及它针对的对象。

以下是一个简单的 Message 对象的类定义:

public class Message {
  public string type;
  public Message() { type = this.GetType().Name; }
}

Message 类的构造函数将消息的 type 缓存到一个本地的 string 属性中,以便稍后用于分类和分发目的。缓存这个值非常重要,因为每次调用 GetType().Name 都会分配一个新的字符串,而我们之前已经了解到我们希望尽可能减少这种活动。

任何自定义消息都可以包含它们希望包含的任何多余数据,只要它们源自这个基类,这将允许它通过我们的消息系统发送。请注意,尽管在基类构造函数期间从对象中获取了type,但name属性仍然包含派生类的名称,而不是基类的名称。

接下来,让我们转向我们的MessagingSystem类,我们应该通过我们需要它满足的要求来定义其功能:

  • 它应该是全局可访问的

  • 任何对象(MonoBehaviour或不是)都应该能够注册/注销作为监听器以接收特定的消息类型(即观察者设计模式)

  • 注册对象应提供在从其他地方广播给定消息时可以调用的方法

  • 系统应该在合理的时间内将消息发送给所有监听器,但不会因为过多的请求而阻塞

一个全局可访问的对象

第一个要求使得消息系统成为单例对象的绝佳候选者,因为我们只需要系统的一个实例。尽管如此,在承诺实现单例之前深思熟虑是否真的是这样是明智的。

如果我们后来决定我们希望存在多个此类对象的实例,希望允许系统在运行时创建/销毁,或者甚至希望创建允许我们在测试过程中伪造或创建/销毁它们的测试用例,那么从代码库中重构单例可能是一项艰巨的任务。这是由于我们将逐渐引入到我们的代码中的所有依赖项。

如果我们希望避免上述缺点而避免使用单例,那么在初始化期间创建消息系统的单个实例,然后根据需要将其传递给子系统,或者我们可能希望进一步探索依赖注入的概念,该概念试图解决这些问题。然而,为了简单起见,我们将假设单例符合我们的需求,并据此设计我们的MessagingSystem类。

注册

第二和第三个要求可以通过提供一些公共方法来实现,这些方法允许与消息系统进行注册。如果我们强制监听对象提供一个在消息广播时调用的委托函数,那么这允许监听器自定义针对哪种消息调用哪种方法。如果我们根据要处理的消息命名委托,我们可以使我们的代码库非常易于理解。

在某些情况下,我们可能希望广播一个通用通知消息,并让所有监听器做出响应,例如一个敌人已创建消息。在其他时候,我们可能发送一个专门针对组中单个监听器的消息。例如,我们可能想发送一个敌人生命值已更改消息,该消息旨在针对被损坏的敌人附着的特定生命值条对象。然而,场景中可能有多个生命值条对象,所有这些对象都对这种消息类型感兴趣,但每个对象只对其提供的敌人生命值更新消息感兴趣。因此,如果我们实现一种让系统在处理完毕后停止检查的方法,那么当许多监听器都在等待相同类型的消息时,我们可能可以节省大量的 CPU 周期。

因此,我们定义的委托应该提供一种通过参数检索消息的方式,并返回一个响应,以确定是否停止对消息的处理,如果监听器处理完毕。是否停止处理的决定可以通过返回一个简单的布尔值来实现,其中true表示此监听器已处理消息,必须停止消息的处理,而false表示此监听器未处理消息,消息系统应尝试下一个监听器。

下面是委托的定义:

public delegate bool MessageHandlerDelegate(Message message);

监听器必须定义这种形式的方法,并在注册期间将委托引用传递给消息系统,从而为消息系统提供一种方式,告诉正在监听的对象消息正在广播。

消息处理

我们的消息系统最终需求是,这个对象应该内置某种基于时间的机制,以防止它因一次性接收太多消息而阻塞。这意味着在代码库的某个地方,我们需要使用MonoBehaviour事件回调来告诉我们的消息系统在 Unity 的Update()期间执行工作,从而使其能够计算时间。

这可以通过我们之前定义的静态类单例(singleton)来实现,这将需要一个基于MonoBehaviourGod类来调用它,通知它场景已经被更新。或者,我们可以使用单例组件来实现相同的功能,它有自己确定何时调用Update()的方法,因此可以独立于任何God类处理其工作负载。这两种方法之间最显著的区别是系统是否依赖于其他对象的控制以及管理单例组件的各种优缺点(这样它就不会在场景之间被销毁;我们不希望在关闭时意外地重新创建它)。

单例组件方法可能是最好的,因为并不是在所有情况下我们都不希望这个系统独立运行,即使我们的大部分游戏逻辑都依赖于它。例如,即使游戏暂停,我们也不希望游戏逻辑暂停我们的消息系统。我们仍然希望消息系统能够继续接收和处理消息,以便我们可以在游戏处于暂停状态时,保持与 UI 相关的组件之间的通信。

实现消息系统

让我们通过从 SingletonComponent 类派生来定义我们的消息系统,并为对象提供一个注册它的方法:

using System.Collections.Generic;
using UnityEngine;

public class MessagingSystem : SingletonComponent<MessagingSystem> {
  public static MessagingSystem Instance {
    get { return ((MessagingSystem)_Instance); }
    set { _Instance = value; }
  }

  private Dictionary<string,List<MessageHandlerDelegate>> _listenerDict = new Dictionary<string,List<MessageHandlerDelegate>>();

  public bool AttachListener(System.Type type, MessageHandlerDelegate handler) {
    if (type == null) {
      Debug.Log("MessagingSystem: AttachListener failed due to having no " + 
                "message type specified");
      return false;
    }

    string msgType = type.Name;
    if (!_listenerDict.ContainsKey(msgType)) {
      _listenerDict.Add(msgType, new List<MessageHandlerDelegate>());
    }

    List<MessageHandlerDelegate> listenerList = _listenerDict[msgType];
    if (listenerList.Contains(handler)) {
      return false; // listener already in list
    }

    listenerList.Add(handler);
    return true;
  }
}

_listenerDict 字段是一个字符串映射到包含 MessageHandlerDelegate 的列表的字典。这个字典根据它们希望监听的消息类型将我们的监听器委托组织到列表中。因此,如果我们知道正在发送什么消息类型,那么我们可以快速检索已为该消息类型注册的所有委托的列表。然后我们可以遍历列表,查询每个监听器,检查它们是否想要处理它。

AttachListener() 方法需要两个参数:一个表示消息类型的 System.Type 和一个 MessageHandlerDelegate,当给定的消息类型通过系统时,将使用它发送消息。

消息排队和处理

为了处理消息,我们的消息系统应该维护一个传入消息对象的队列,以便我们可以按它们被广播的顺序处理它们:

private Queue<Message> _messageQueue = new Queue<Message>();

public bool QueueMessage(Message msg) {
  if (!_listenerDict.ContainsKey(msg.type)) {
    return false;
  }
  _messageQueue.Enqueue(msg);
  return true;
}

QueueMessage() 方法简单地检查在将其添加到队列之前,给定的消息类型是否存在于我们的字典中。这实际上测试了在将消息排队以供稍后处理之前,对象是否真的关心监听该消息。为此,我们引入了一个新的 private 字段,_messageQueue

接下来,我们将添加 Update() 的定义。这个回调将由 Unity 引擎定期调用。它的目的是逐个遍历消息队列的当前内容;验证自我们开始处理以来是否已经过去了太多时间;如果没有,就将它们传递到处理过程的下一阶段:

private const int _maxQueueProcessingTime = 16667;
private System.Diagnostics.Stopwatch timer = new System.Diagnostics.Stopwatch();

void Update() {
  timer.Start();
  while (_messageQueue.Count > 0) {
    if (_maxQueueProcessingTime > 0.0f) {
      if (timer.Elapsed.Milliseconds > _maxQueueProcessingTime) {
         timer.Stop();
         return;
      }
    }

    Message msg = _messageQueue.Dequeue();
    if (!TriggerMessage(msg)) {
      Debug.Log("Error when processing message: " + msg.type);
    }
  }
}

基于时间的保护措施旨在确保它不会超过处理时间限制阈值。这防止了消息系统在太多消息快速通过系统时冻结我们的游戏。如果总时间限制超过,则所有消息处理将停止,任何剩余的消息将在下一帧进行处理。

注意,我们在创建 Stopwatch 对象时使用了完整的命名空间。我们本来可以添加 using System.Diagnostics,但这会导致 System.Diagnostics.DebugUnityEngine.Debug 之间的命名空间冲突。省略它允许我们继续使用 Debug.Log() 调用 Unity 的调试记录器,而无需每次都显式调用 UnityEngine.Debug.Log()

最后,我们需要定义 TriggerMessage() 方法,它将消息分配给监听器:

public bool TriggerMessage(Message msg) {
  string msgType = msg.type;
  if (!_listenerDict.ContainsKey(msgType)) {
    Debug.Log("MessagingSystem: Message \"" + msgType + "\" has no listeners!");
    return false; // no listeners for message so ignore it
  }

  List<MessageHandlerDelegate> listenerList = _listenerDict[msgType];

  for(int i = 0; i < listenerList.Count; ++i) {
    if (listenerListi) {
      return true; // message consumed by the delegate
    }
    return true;
  }  
}

上述方法是消息系统背后的主要工作马。TriggerEvent() 方法的目的是获取给定消息类型的监听器列表,并给每个监听器一个处理它的机会。如果其中一个委托返回 true,则当前消息的处理将停止,方法退出,允许 Update() 方法处理下一个消息。

通常,我们希望使用 QueueEvent() 来广播消息,但我们也提供了对 TriggerEvent() 的直接访问作为替代。直接使用 TriggerEvent() 允许消息发送者强制其消息立即处理,而无需等待下一个 Update() 事件。这绕过了节流机制,这对于需要在游戏关键时刻发送的消息可能是必要的,等待额外的帧可能会导致看起来奇怪的行为。

例如,如果我们打算让两个对象在相互碰撞时被销毁并创建一个粒子效果,这项工作由另一个子系统处理(因此需要发送一个事件),那么我们希望通过 TriggerEvent() 发送消息,以防止对象在事件处理之前再存在一帧。相反,如果我们想做一些不那么帧关键的事情,比如当玩家走进一个新的区域时创建一个弹出消息,我们可以安全地使用 QueueEvent() 调用来处理它。

尽量避免习惯性地对所有事件使用 TriggerEvent(),因为我们可能会在同一帧内同时处理过多的调用,导致帧率突然下降。决定哪些事件是帧关键性的,哪些不是,并适当地使用 QueueEvent()TriggerEvent() 方法。

实现自定义消息

我们已经创建了消息系统,但一个如何使用它的例子将帮助我们更好地理解这个概念。让我们从定义一对从 Message 派生的简单类开始,我们可以使用这些类来创建一个新的敌人,以及通知代码库的其他部分敌人已被创建:

public class CreateEnemyMessage : Message {}

public class EnemyCreatedMessage : Message {

  public readonly GameObject enemyObject;
  public readonly string enemyName;

  public EnemyCreatedMessage(GameObject enemyObject, string enemyName) {
    this.enemyObject = enemyObject;
    this.enemyName = enemyName;
  }
}

CreateEnemyMessage 是最简单的消息形式,不包含特殊数据,而 EnemyCreatedMessage 将包含对敌人 GameObject 的引用以及其名称。对于消息对象的好做法是使它们的成员变量不仅是 public 的,也是 readonly 的。这确保了数据易于访问,但在对象构造之后不能被更改。这保护了消息的内容不被更改,因为它们在监听器之间传递。

消息发送

要发送这些消息对象之一,我们只需调用QueueEvent()TriggerEvent(),并传递我们希望发送的消息的实例。以下代码演示了当按下空格键时,我们如何广播CreateEnemyMessage对象:

public class EnemyCreatorComponent : MonoBehaviour {
  void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
      MessagingSystem.Instance.QueueMessage(new CreateEnemyMessage()); 
    }
  }
}

如果我们现在测试此代码,将不会发生任何事情,因为尽管我们通过消息系统发送消息,但没有任何监听器为此消息类型。让我们来看看如何将监听器注册到消息系统中。

消息注册

以下代码包含一对简单的类,它们注册到消息系统中,每个类都请求在其代码库的任何地方广播特定类型的消息时调用它们的方法:


public class EnemyManagerWithMessagesComponent : MonoBehaviour {
  private List<GameObject> _enemies = new List<GameObject>();
  [SerializeField] private GameObject _enemyPrefab;

  void Start() {
    MessagingSystem.Instance.AttachListener(typeof(CreateEnemyMessage), 
                                            this.HandleCreateEnemy);
  }

  bool HandleCreateEnemy(Message msg) {
    CreateEnemyMessage castMsg = msg as CreateEnemyMessage;
    string[] names = { "Tom", "Dick", "Harry" };
    GameObject enemy = GameObject.Instantiate(_enemyPrefab, 
                       5.0f * Random.insideUnitSphere, 
                       Quaternion.identity);
    string enemyName = names[Random.Range(0, names.Length)];
    enemy.gameObject.name = enemyName;
    _enemies.Add(enemy);
    MessagingSystem.Instance.QueueMessage(new EnemyCreatedMessage(enemy, 
                                                                  enemyName));
    return true;
  }
}

public class EnemyCreatedListenerComponent : MonoBehaviour {
  void Start () {
    MessagingSystem.Instance.AttachListener(typeof(EnemyCreatedMessage), 
                                            HandleEnemyCreated);
  }

  bool HandleEnemyCreated(Message msg) {
    EnemyCreatedMessage castMsg = msg as EnemyCreatedMessage;
    Debug.Log(string.Format("A new enemy was created! {0}", 
                            castMsg.enemyName));
    return true;
  }
}

在初始化期间,EnemyManagerWithMessagesComponent类注册接收CreateEnemyMessage类型的消息,并通过其HandleCreateEnemy()代理处理这些消息。在这个过程中,它可以将其转换为适当的派生消息类型,并以其独特的方式解析消息。其他类可以注册相同的消息,并通过其自定义代理方法以不同的方式解析(假设早期监听器没有从其自己的代理返回true)。

我们知道HandleCreateEnemy()方法的msg参数将提供哪种类型的消息,因为我们通过AttachListener()调用在注册期间定义了它。因此,我们可以确信我们的类型转换是安全的,我们可以通过不必进行null引用检查来节省时间,尽管技术上没有阻止我们使用相同的代理来处理多个消息类型。然而,在这些情况下,我们需要实现一种方法来确定传递的是哪个消息对象,并相应地处理它。但是,最佳方法是为每种消息类型定义一个独特的方法,以保持适当的解耦。试图使用一个单一的方法来处理所有消息类型实际上几乎没有好处。

注意HandleEnemyCreated()方法定义与MessageHandlerDelegate函数签名(即,具有相同的返回类型和参数列表)相匹配,并且它在AttachListener()调用中被引用。这就是我们告诉消息系统在给定消息类型被广播时调用哪个方法的方式,以及委托如何确保类型安全。

如果函数签名有不同的返回值或不同的参数列表,那么它将不是AttachListener()方法的有效委托,我们会得到编译器错误。此外,请注意HandleEnemyCreated()是一个private方法,但我们的MessagingSystem类可以调用它。这是委托的一个有用特性,我们可以只允许我们给予权限的系统调用这个消息处理器。公开方法可能会导致我们代码的 API 中的一些混淆,开发者可能会认为他们可以直接调用该方法,但这不是它的预期用途。

美妙的部分在于我们可以自由地为委托方法命名。最合理的方法是按照它处理的消息来命名方法。这使任何阅读我们代码的人都能清楚地知道该方法的作用以及必须发送什么类型的消息对象来调用它。这使得我们代码的将来解析和调试变得更加简单,因为我们可以通过消息和它们的处理委托的匹配名称来跟踪事件链。

HandleCreateEnemy()方法中,我们还排队另一个事件,它广播EnemyCreatedMessage。第二个类EnemyCreatedListenerComponent注册接收这些消息,然后打印出包含该信息的消息。这就是我们实现子系统通知其他子系统变化的方式。在实际应用中,我们可能会注册一个 UI 系统来监听这些类型的消息,并在屏幕上更新计数器以显示现在有多少敌人处于活动状态。在这种情况下,敌人和 UI 系统是适当解耦的,这样它们就不需要知道任何关于对方如何操作的具体信息,以便完成它们分配的任务。

如果我们现在将EnemyManagerWithMessagesComponentEnemyCreatorComponentEnemyCreatedListenerComponent添加到我们的场景中,并多次按下空格键,我们应该在控制台窗口中看到日志消息出现,告知我们测试成功:

图片

注意,在场景初始化期间,当EnemyManagerWithMessagesComponentEnemyCreatedListenerComponent对象的Start()方法被调用时(哪个先发生),将创建一个MessagingSystem单例对象,因为那时它们将它们的委托注册到消息系统中,该系统访问Instance属性,因此创建了包含单例组件的必要GameObject实例。我们不需要做任何额外的工作来创建MessagingSystem对象。

消息清理

由于消息对象是类,它们将在内存中动态创建,并在消息被处理并分发给所有监听器后不久被销毁。然而,正如你将在第八章“精通内存管理”中了解到的那样,这最终会导致垃圾回收,因为随着时间的推移,内存会积累。如果我们的应用程序运行时间足够长,最终会导致偶尔的垃圾回收,这是 Unity 应用程序中意外和突然的 CPU 性能激增的最常见原因。因此,明智的做法是谨慎使用消息系统,并避免在每次更新时频繁地发送消息。

需要考虑的重要清理操作是在对象需要被销毁时注销代表。如果我们处理不当,那么消息系统将保留代表引用,这会阻止对象被完全销毁并从内存中释放。

实际上,当对象被销毁、禁用或我们决定不再需要它在消息发送时被查询时,我们需要将每个AttachListener()调用与适当的DetachListener()调用配对。

MessagingSystem类中的以下方法定义将断开特定事件的监听器:

public bool DetachListener(System.Type type, MessageHandlerDelegate handler) {
  if (type == null) {
    Debug.Log("MessagingSystem: DetachListener failed due to having no " + 
              "message type specified");
    return false;
  }

  string msgType = type.Name;

  if (!_listenerDict.ContainsKey(type.Name)) {
    return false;
  }

  List<MessageHandlerDelegate> listenerList = _listenerDict[msgType];
  if (!listenerList.Contains (handler)) {
    return false;
  }
  listenerList.Remove(handler);
  return true;
}

下面是添加到我们的EnemyManagerWithMessagesComponent类中的DetachListener()方法的一个示例用法:

void OnDestroy() {
  if (MessagingSystem.IsAlive) {
    MessagingSystem.Instance.DetachListener(typeof(EnemyCreatedMessage), 
                                            this.HandleCreateEnemy);
  }
}

注意这个定义是如何使用在SingletonComponent类中声明的IsAlive属性的。这保护我们免受在应用程序关闭期间意外创建新的MessagingSystem类的问题,因为我们永远无法保证单例是最后一个被销毁的。

总结消息系统

现在我们已经最终构建了一个完全功能化的全局消息系统,任何对象都可以与之交互并使用它来相互发送消息。这种方法的一个有用特性是它是Type无关的,这意味着消息发送者和监听者甚至不需要从任何特定的类派生出来,以便与消息系统交互;它只需要是一个提供消息类型和匹配函数签名的委托函数的类,这使得它对普通类和 MonoBehaviours 都可用。

MessagingSystem类的基准测试而言,我们会发现它能够在单个帧中处理数百甚至数千条消息,同时 CPU 开销最小(当然,这取决于 CPU)。无论是一条消息被分发到 100 个不同的监听器,还是 100 条消息被分发到仅一个监听器,CPU 的使用率基本上是相同的。两种情况下,成本大致相同。

即使我们在 UI 或游戏事件期间主要发送消息,这也可能比我们需要的强大得多。所以,如果它似乎确实导致了性能问题,那么更有可能是由监听器委托处理消息的方式造成的,而不是消息系统能够处理这些消息的能力。

有许多方法可以增强消息系统,以提供我们未来可能需要的更多有用功能,如下所示:

  • 允许消息发送者在消息传递给监听器之前建议一个延迟(时间或帧数)。

  • 允许消息监听器定义一个优先级,以确定它相对于其他等待相同消息类型的监听器接收消息的紧急程度。这是监听器在注册晚于其他监听器时跳到队列前面的一个方法。

  • 实现一些安全检查来处理在监听器被添加到特定消息的消息监听器列表中,而该类型的消息仍在处理时的情况。目前,C#会抛出EnumerationException,因为AttachListener()会更改委托列表,而它仍在TriggerEvent()中迭代。

到目前为止,我们可能已经足够了解消息系统了,所以这些任务将留给你作为学术练习,如果你在使用这个解决方案时感到舒适。让我们继续探索更多通过脚本代码提高性能的方法。

禁用未使用的脚本和对象

场景有时会非常繁忙,尤其是在我们构建大型开放世界时。调用Update()回调函数的对象越多,其扩展性越差,游戏运行速度越慢。然而,如果这些处理在玩家视野之外,或者距离太远以至于无关紧要,那么其中很大一部分可能是完全不必要的。这在大型城市模拟游戏中可能不是一个可行的选择,因为在所有时候都必须处理整个模拟,但在第一人称和赛车游戏中通常是可以的,因为玩家在一个广阔的区域中四处游荡,非可见对象可以暂时禁用,而不会对游戏玩法产生任何明显的影响。

通过可见性禁用对象

有时,我们可能希望当组件或 GameObject 不可见时禁用它们。Unity 自带内置的渲染功能,可以避免渲染玩家摄像机视野之外的物体(通过称为视锥剔除的技术,这是一个自动过程),以及避免渲染被其他物体遮挡的物体(遮挡剔除,将在第六章动态图形中讨论),但这些只是渲染优化。视锥和遮挡剔除不会影响在 CPU 上执行任务的组件,如 AI 脚本、用户界面和游戏逻辑。我们必须自己控制它们的行为。

解决这个问题的好方法是使用OnBecameVisible()OnBecameInvisible()回调。正如其名称所暗示的,这些回调方法在可渲染对象相对于场景中的任何相机变得可见或不可见时被调用。此外,当场景中有多个相机(例如,本地多人游戏)时,只有当对象对任何相机可见而对所有相机不可见时,回调才会被调用。这意味着上述回调将在我们期望的确切时间被调用;如果没有人能看到它,OnBecameInvisible()将被调用,如果至少有一个玩家能看到它,OnBecameVisible()将被调用。

由于可见性回调必须与渲染管线通信,GameObject必须附加一个可渲染组件,例如MeshRendererSkinnedMeshRenderer。我们必须确保我们想要接收可见性回调的组件也附加到与可渲染对象相同的GameObject实例上,并且不是父或子GameObject;否则,它们不会被调用。

注意,Unity 也将场景窗口的隐藏相机计算到OnBecameVisible()OnBecameInvisible()回调中。如果我们发现这些方法在 Playmode 测试期间没有被正确调用,确保将场景窗口的相机远离所有对象,或者完全禁用场景窗口。

要使用可见性回调来启用/禁用单个组件,我们可以添加以下方法:

void OnBecameVisible() { enabled = true; }
void OnBecameInvisible() { enabled = false; }

此外,要启用/禁用组件附加到的整个GameObject,我们可以这样实现方法:

void OnBecameVisible() { gameObject.SetActive(true); }
void OnBecameInvisible() { gameObject.SetActive(false); }

然而,请注意,禁用包含可渲染对象的GameObject或其父对象,将使得OnBecameVisible()无法被调用,因为现在没有图形表示供相机看到并触发回调。我们应该将组件放置在子GameObject上,并让脚本禁用该子对象,这样可渲染对象始终保持可见(或找到其他方法在稍后重新启用它)。

通过距离禁用对象

在其他情况下,我们可能希望组件或 GameObject 在距离玩家足够远时被禁用,这样它们可能几乎可见,但太远以至于无关紧要。这种活动的合适候选者是游荡的非玩家角色生物:我们希望在远处看到它们,但不需要它们处理任何事情,因此它们可以闲置直到我们靠近。

以下代码是一个简单的协程,它定期检查给定目标对象的总距离,并在偏离太远时禁用自己:

[SerializeField] GameObject _target;
[SerializeField] float _maxDistance;
[SerializeField] int _coroutineFrameDelay;

void Start() {
  StartCoroutine(DisableAtADistance());
}

IEnumerator DisableAtADistance() {
  while(true) {
    float distSqrd = (transform.position - _target.transform.position).sqrMagnitude;
    if (distSqrd < _maxDistance * _maxDistance) {
      enabled = true;
    } else {
      enabled = false;
    }

    for (int i = 0; i < _coroutineFrameDelay; ++i) {
      yield return new WaitForEndOfFrame();
    }
  }
}

我们应该在检查器窗口中将玩家的角色对象(或我们想要与之比较的任何对象)分配给_target字段,在_maxDistance中定义最大距离,并使用_coroutineFrameDelay字段修改协程调用的频率。每当对象距离分配给_target的对象超过_maxDistance距离时,它将被禁用。如果它返回到该距离内,它将被重新启用。

这种实现的微妙性能增强特性是使用距离平方而不是原始距离进行比较。这很方便地引出了我们下一个部分。

使用距离平方而不是距离

可以说,CPU 在乘以浮点数方面相对较好,但在从它们中计算平方根方面相对较差。每次我们要求Vector3使用magnitude属性或Distance()方法计算距离时,我们都在要求它执行平方根计算(根据勾股定理),这与其他类型的向量数学计算相比可能会消耗大量的 CPU 开销。

然而,Vector3类还提供了一个sqrMagnitude属性,它提供了与距离相同的结果,只是值是平方的。这意味着如果我们也将我们想要比较距离的值平方,那么我们可以不进行昂贵的平方根计算而进行本质上相同的比较。

例如,考虑以下代码:

float distance = (transform.position – other.transform.position).Distance();
if (distance < targetDistance) {
  // do stuff
}

这可以替换为以下代码,并实现几乎相同的结果:

float distanceSqrd = (transform.position – other.transform.position).sqrMagnitude;
if (distanceSqrd < (targetDistance * targetDistance)) {
  // do stuff
}

结果几乎相同的原因是浮点精度。我们可能会失去使用平方根值所拥有的部分精度,因为值将被调整到一个具有不同可表示数字密度的区域;它可能正好落在或更接近一个更精确的可表示数字上,或者更有可能,它将落在精度较低的数字上。因此,比较并不完全相同,但在大多数情况下,它足够接近以至于不明显,并且以这种方式替换每个指令的性能提升可以非常显著。

如果这种轻微的精度损失并不重要,那么应该考虑这种性能技巧。然而,如果精度非常重要(例如运行精确的大型星际空间模拟),那么你可能想跳过这个提示。

注意,这种技术可以用于任何平方根计算,而不仅仅是距离。这只是一个你可能遇到的常见例子,它突出了Vector3类的重要sqrMagnitude属性。这是一个 Unity Technologies 有意暴露给我们以便以这种方式使用的属性。

最小化反序列化行为

Unity 的序列化系统主要用于场景、Prefab、ScriptableObjects 以及各种资产类型(这些通常从 ScriptableObject 派生)。当这些对象类型之一被保存到磁盘时,它会被转换为使用另一种标记语言YAML)格式的文本文件,稍后可以反序列化回原始对象类型。当 Prefab 或场景被序列化时,所有 GameObject 及其属性都会被序列化,包括privateprotected字段以及所有组件,以及子 GameObject 及其组件等等。

当我们的应用程序构建时,这种序列化数据在 Unity 内部以大型二进制数据文件的形式捆绑在一起,称为序列化文件。在运行时从磁盘读取和反序列化这些数据是一个极其缓慢的过程(相对而言),因此所有反序列化活动都伴随着显著的性能成本。

这种类型的反序列化发生在我们调用Resources.Load()为名为Resources的文件夹下的文件路径时。一旦数据从磁盘加载到内存中,然后稍后重新加载相同的引用会更快,但第一次访问时始终需要磁盘活动。自然地,我们需要反序列化的数据集越大,这个过程就越长。由于 Prefab 的每个组件都会被序列化,因此层次结构越深,需要反序列化的数据就越多。

这对于具有非常深层次结构的 Prefab、具有许多空 GameObject 的 Prefab(因为每个GameObject都至少包含一个Transform组件)以及尤其是对于用户界面UI)Prefab 来说可能是一个问题,因为它们通常包含比典型 Prefab 更多的组件。

加载这类大型序列化数据集可能会在首次加载时导致 CPU 使用量显著增加,如果它们在场景开始时立即需要,这往往会增加加载时间。更重要的是,如果在运行时加载,它们可能会导致帧率下降。我们可以采用几种方法来最小化反序列化的成本。

减少序列化对象的大小

我们应该努力使我们的序列化对象尽可能小,或者将它们分成更小的数据块,我们逐个将它们组合在一起,这样它们就可以在一段时间内逐个加载。对于 Prefab 来说,这可能很难管理,因为 Unity 本身不支持嵌套 Prefab,因此我们可能需要自己实现这样一个系统,这在 Unity 中是一个臭名昭著的难题。UIPrefab 是分离成更小块的好候选,因为我们通常不需要在任何给定时刻加载整个 UI,因此我们通常可以一次加载一个部分。

异步加载序列化对象

预制体和其他序列化内容可以通过Resources.LoadAsync()异步加载,这将把从磁盘读取的工作卸载到工作线程,从而减轻主线程的负担。序列化对象可用需要一些时间,可以通过调用前一个方法调用返回的ResourceRequest对象的isDone属性来检查。

这对于游戏开始时就需要立即使用的预制体来说并不理想,但如果我们愿意创建管理系统来管理这种行为,那么所有未来的预制体都是异步加载的良好候选者。

保持之前加载的序列化对象在内存中

如前所述,一旦序列化对象被加载到内存中,它就会保留在那里,如果以后需要,可以复制它,例如实例化更多预制体的副本。我们可以通过显式调用Resources.Unload()来释放这些数据,这将释放内存空间以供以后重用。但是,如果我们有大量的剩余内存空间,我们可以选择将这些数据保留在内存中,这会减少以后从磁盘重新加载的需要。这自然会消耗越来越多的内存,随着序列化数据的增加,这会使得内存管理策略变得风险较大,因此我们只有在必要时才这样做。

将公共数据移动到ScriptableObjects

如果我们有很多不同的预制体,它们包含大量倾向于共享数据的组件,例如游戏设计值,如生命值、力量和速度,那么所有这些数据都将序列化到使用它们的每个预制体中。更好的方法是序列化这些公共数据到ScriptableObject中,它们将加载并使用这些数据。这减少了预制体序列化文件中存储的数据量,并且可以通过避免过多重复工作显著减少场景的加载时间。

附加和异步加载场景

场景可以加载以替换当前场景,也可以附加加载以将其内容添加到当前场景中而不卸载先前的场景。这可以通过SceneManager.LoadScene()函数系列的LoadSceneMode参数来切换。

另一种场景加载模式是同步或异步地完成它,使用这两种方法都有很好的理由。同步加载是通过调用SceneManager.LoadScene()来加载场景的典型方法,此时主线程将阻塞,直到指定的场景加载完成。这通常会导致用户体验不佳,因为当内容加载时(无论是替换还是附加),游戏看起来会冻结。这最好用于我们希望尽快让玩家进入行动,或者我们没有时间等待场景对象出现的情况。通常情况下,如果我们正在加载游戏的第一级或返回主菜单,会使用这种方法。

然而,对于未来的场景加载,我们可能希望减少性能影响,以便我们可以继续让玩家保持活跃。加载一个场景可能需要大量工作,场景越大,所需时间越长。然而,异步增量加载的选项提供了巨大的好处:我们可以让场景在后台逐渐加载,而不会对用户体验造成重大影响。这可以通过SceneManager.LoadSceneAsync()实现,并结合传递LoadSceneMode.Additive作为加载模式参数。

重要的是要认识到,场景并不严格遵循游戏关卡的概念。在大多数游戏中,玩家通常一次被困在一个关卡中,但 Unity 可以通过增量加载支持同时加载多个场景,允许每个场景代表关卡的一小部分。因此,我们可以初始化关卡的第一个场景(Scene-1-1a),当玩家接近下一部分时,异步和增量加载下一个(Scene-1-1b),然后随着玩家在关卡中的移动持续重复这个过程。

利用这个功能需要一种系统,要么不断检查玩家在关卡中的位置直到他们接近,要么使用触发体积来广播一个消息,表明玩家即将进入下一部分,并在适当的时间开始异步加载。另一个重要的考虑因素是,由于异步加载有效地将加载分散在几个帧上以尽可能减少可见影响,场景的内容不会立即出现。我们需要确保我们有足够的时间触发异步场景加载,这样玩家就不会看到对象突然出现在游戏中。

场景也可以被卸载以从内存中清除。这将节省一些内存或运行时性能,通过移除不再需要的任何使用Update()组件来实现。再次强调,这可以通过SceneManager.UnloadScene()SceneManager.UnloadSceneAsync()同步和异步地完成。这可以带来巨大的性能提升,因为我们只使用玩家在关卡中的位置所需要的内容,但请注意,不可能卸载单体场景的小部分。如果原始场景文件非常大,那么卸载它将卸载一切。原始场景必须被分割成更小的场景,然后根据需要加载和卸载。同样,我们只有在确定玩家无法再看到其组成对象时才开始卸载场景;否则,他们会看到对象突然消失。最后一个考虑因素是,场景卸载会触发许多对象的销毁,这可能会释放大量内存并触发垃圾回收器。在利用这个技巧时,高效地使用内存也很重要。

这种方法将需要大量的场景重新设计工作、脚本编写、测试和调试,这不可小觑,但改善用户体验的好处是显著的。在游戏中不同区域之间实现无缝过渡是玩家和评论家经常称赞的优点,因为它不会让玩家脱离游戏。如果我们恰当地使用它,可以节省大量的运行时性能,进一步提高用户体验。

创建一个自定义的 Update() 层

在本章前面的 更新、协程和 InvokeRepeating 部分,我们讨论了使用这些 Unity 引擎功能作为避免大多数帧中过度 CPU 工作负载的相对优缺点。无论我们可能采用哪种方法,都存在一个额外的风险,即许多 MonoBehaviours 都会定期调用某个函数,这会导致在同一帧内同时触发太多方法。

想象一下,在场景开始时一起初始化成千上万的 MonoBehaviours,每个 MonoBehaviours 都在相同的时间启动一个协程,每 500 毫秒处理一次它们的 AI 任务。它们很可能都在同一帧内触发,导致 CPU 使用率瞬间激增,然后暂时稳定下来,几秒钟后又因为下一轮 AI 处理而再次激增。理想情况下,我们希望将这些调用分散到不同的时间。

以下是对这个问题的可能解决方案:

  • 每次计时器到期或协程触发时,生成一个随机的等待时间

  • 将协程初始化分散开来,以便每帧只启动少数几个

  • 将调用更新责任传递给一个名为 God 的类,该类对每帧发生的调用次数进行限制

前两种方法很有吸引力,因为它们相对简单,而且我们知道协程可以潜在地为我们节省很多不必要的开销。然而,正如我们讨论的那样,这种激进的设计变化有许多危险和意外的副作用。

优化更新的一个潜在更好的方法是完全不使用 Update()——或者更准确地说,只使用一次。当 Unity 调用 Update() 时,实际上是其任何回调,它都会跨越上述原生-托管桥,这可能是一项昂贵的任务。换句话说,执行 1,000 个单独的 Update() 回调的处理成本将比执行一个 Update() 回调(该回调调用 1,000 个常规函数)更高。正如我们在 <q>删除空回调定义</q> 部分所见证的,调用 Update() 数千次对 CPU 来说不是一项微不足道的工作,主要是因为桥接。因此,我们可以通过让一个 GodMonoBehaviour 使用其自己的 Update() 回调来调用我们自定义的更新风格系统(该系统由我们的自定义组件使用)来最小化 Unity 需要跨越桥的频率。

实际上,许多 Unity 开发者更喜欢从项目一开始就实现这种设计,因为它让他们能够更精细地控制更新何时以及如何在整个系统中传播;这可以用于菜单暂停、冷却时间操纵效果,或者在我们检测到即将达到当前帧的 CPU 预算时,优先处理重要任务和/或挂起低优先级任务。

所有希望与这种系统集成的对象都必须有一个公共的入口点。我们可以通过使用 interface 关键字的 Interface 类来实现这一点。Interface 是一种代码结构,用于基本上设置一个合同,即任何实现 Interface 类的类都必须提供一系列特定的方法。换句话说,如果我们知道对象实现了 Interface 类,那么我们可以确定有哪些方法可用。在 C# 中,类只能从单个基类派生,但它们可以实现任意数量的 Interface 类(这避免了 C++ 程序员熟悉的 致命菱形死亡 问题)。

以下 Interface 类定义就足够了,它只要求实现类定义一个名为 OnUpdate() 的方法:

public interface IUpdateable {
  void OnUpdate(float dt);
}

通常的做法是以大写 "I" 开头定义 Interface 类,以使其清晰表明我们正在处理的是一个 Interface 类。Interface 类的美丽之处在于它们提高了我们代码库的解耦,允许替换巨大的子系统,并且只要坚持 Interface 类,我们将更有信心它将继续按预期工作。

接下来,我们将定义一个自定义的 MonoBehaviour 类型,该类型实现了这个 Interface 类:

public class UpdateableComponent : MonoBehaviour, IUpdateable {
  public virtual void OnUpdate(float dt) {}
}

注意,我们给这个方法命名为 OnUpdate() 而不是 Update()。我们正在定义同一概念的定制版本,但希望避免与内置的 Update() 回调发生名称冲突。

UpdateableComponent类的OnUpdate()方法检索当前的时间增量(dt),这使我们免去了许多不必要的Time.deltaTime调用,这些调用通常用于Update()回调中。我们还创建了一个virtual函数,以允许派生类自定义它。

这个函数永远不会被调用,因为它目前是这样编写的。Unity 会自动获取并调用名为Update()的方法,但它没有我们OnUpdate()函数的概念,因此我们需要实现一些东西,在适当的时候调用这个方法。例如,可以使用某种GameLogicGod类来完成这个目的。

在此组件初始化期间,我们应该做一些事情来通知我们的GameLogic对象其存在和销毁,以便它知道何时开始和停止调用其OnUpdate()函数。

在下面的示例中,我们将假设我们的GameLogic类是之前定义的SingletonComponent,在《单例组件》部分中定义,并且为注册和注销定义了适当的static函数。请记住,它同样可以使用上述的MessagingSystem来通知GameLogic其创建/销毁。

为了让 MonoBehaviours 能够钩入这个系统,最合适的地方是在它们的Start()OnDestroy()回调中:

void Start() {
  GameLogic.Instance.RegisterUpdateableObject(this);
}

void OnDestroy() {
  if (GameLogic.Instance.IsAlive) {
    GameLogic.Instance.DeregisterUpdateableObject(this);
  }
}

使用Start()方法进行注册任务是最好的,因为使用Start()意味着我们可以确定所有其他现有的组件在此之前至少已经调用了它们的Awake()方法。这样,任何关键初始化工作在我们在其上调用更新之前就已经在对象上完成了。

注意,因为我们在一个MonoBehaviour基类中使用Start(),如果我们在一个派生类中定义一个Start()方法,它将有效地覆盖基类的定义,Unity 将获取派生类的Start()方法作为回调。因此,实现一个虚拟的Initialize()方法是明智的,以便派生类可以覆盖它来自定义初始化行为,而不会干扰基类通知GameLogic对象我们的组件存在的工作。

以下代码提供了一个示例,说明我们如何实现一个虚拟的Initialize()方法:

void Start() {
  GameLogic.Instance.RegisterUpdateableObject(this);
  Initialize();
}

protected virtual void Initialize() {
  // derived classes should override this method for initialization code, and NOT reimplement Start()
}

最后,我们需要实现GameLogic类。无论它是SingletonComponent还是MonoBehaviour,或者是否使用MessagingSystem,实现都是相同的。无论如何,我们的UpdateableComponent类必须注册和注销为IUpdateable对象,而GameLogic类必须使用它自己的Update()回调来遍历每个注册的对象并调用它们的OnUpdate()函数。

这里是GameLogic类的定义:

public class GameLogicSingletonComponent : SingletonComponent<GameLogicSingletonComponent> {
  public static GameLogicSingletonComponent Instance {
    get { return ((GameLogicSingletonComponent)_Instance); }
    set { _Instance = value; }
  }

  List<IUpdateable> _updateableObjects = new List<IUpdateable>();

  public void RegisterUpdateableObject(IUpdateable obj) {
    if (!_updateableObjects.Contains(obj)) {
      _updateableObjects.Add(obj);
    }
  }

  public void DeregisterUpdateableObject(IUpdateable obj) {
    if (_updateableObjects.Contains(obj)) {
      _updateableObjects.Remove(obj);
    }
  } 

  void Update()
  {
    float dt = Time.deltaTime;
    for (int i = 0; i < _updateableObjects.Count; ++i) {
      _updateableObjects[i].OnUpdate(dt);
    }
  }
}

如果我们确保所有的自定义组件都继承自UpdateableComponent类,那么我们就有效地将NUpdate()回调调用替换为仅一次Update()回调调用,以及N次虚函数调用。这可以为我们节省大量的性能开销,因为尽管我们在调用虚函数(这比非虚函数调用多出一些开销,因为它们需要将调用重定向到正确的地方),但我们仍然将绝大多数的更新行为保持在我们的托管代码中,并尽可能避免使用原生-托管桥接。这个类甚至可以扩展以提供优先级系统,如果检测到当前帧耗时过长,则跳过低优先级任务,以及许多其他可能性。

根据你对当前项目的熟悉程度,这样的改变可能会非常令人畏惧、耗时且可能引入大量错误,因为子系统被更新以使用一组完全不同的依赖项。然而,如果时间站在你这边,这些好处可能会超过风险。明智的做法是在一个与你的当前场景文件设计类似的场景中测试一组对象,以验证好处是否超过成本。

摘要

本章向您介绍了许多方法,这些方法将提高您在 Unity 引擎中的脚本实践,并在(且仅在你已经证明某些脚本是性能问题的原因时)提高性能。这些技术中的一些在实施之前需要一些预先思考和性能分析调查,因为它们通常会带来额外的风险或使我们的代码库变得难以理解。工作流程通常与性能和设计一样重要,所以在你对代码进行任何性能更改之前,你应该考虑你是否在性能优化的祭坛上牺牲了太多。

我们将在第八章“精通内存管理”中稍后探讨更高级的脚本改进技术,但现在让我们暂时放下代码,探索一些使用名为动态批处理和静态批处理的一对内置 Unity 功能来提高图形性能的方法。

第二部分:图形优化

读者将学习如何优化 Unity 游戏/应用程序的图形堆栈。本节中的章节如下:

第三章,批处理的好处

第四章,优化你的艺术资源

第五章,更快的物理

第六章,动态图形

第三章:批量处理的益处

在 3D 图形和游戏中,批量处理是一个非常通用的术语,用来描述将大量零散的数据分组在一起,并将它们作为一个单一的大数据块进行处理的过程。这种情况对于 CPU,尤其是 GPU 来说非常理想,因为它们可以通过多个核心同时处理多个任务。单个核心在内存的不同位置之间切换需要时间,所以需要减少这种切换的次数,这样会更好。

在某些情况下,批量处理的行为指的是大量网格、顶点、边、UV 坐标以及其他用于表示 3D 对象的不同数据类型;然而,这个术语同样可以指批量处理音频文件、精灵、纹理文件和其他大型数据集的行为。

因此,为了消除任何混淆,当在 Unity 中提到批量处理时,通常是指它提供的两种主要的批量处理网格数据的机制:动态批量处理静态批量处理。这些方法本质上是一种几何合并的不同形式,我们将多个对象的网格数据合并在一起,并通过一个指令将它们全部渲染出来,而不是分别准备和绘制每一个。

将多个网格批量合并成一个单一网格的过程是可行的,因为网格对象没有必要必须填充连续的 3D 空间体积。渲染管线对接受那些没有通过边连接在一起的顶点集合感到非常满意,因此我们可以将多个可能产生多个渲染指令的独立网格合并成一个单一网格,从而通过一个指令来渲染它。

多年来,关于动态批量处理和静态批量处理系统激活的条件以及我们可能看到性能提升的地方存在很多混淆。毕竟,在某些情况下,如果使用不当,批量处理实际上可能会降低性能。对这些系统的正确理解将给我们提供所需的知识,以显著提高我们应用程序的图形性能。

本章旨在消除关于这些系统存在的许多错误信息。通过解释、探索和示例,我们将看到这两种批量处理方法是如何运作的。这将使我们能够做出明智的决定,并利用其中大部分来提高我们应用程序的性能。

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

  • 渲染管线简介及绘制调用概念

  • Unity 的材料和着色器如何协同工作以渲染我们的对象

  • 使用帧调试器可视化渲染行为

  • 动态批量处理的工作原理及其优化方法

  • 静态批量处理的工作原理及其优化方法

绘制调用

在我们讨论动态批处理和静态批处理之前,让我们首先了解它们都在渲染管线中试图解决的问题。我们将尽量减少对技术细节的分析,因为我们将在第六章“动态图形”中更详细地探讨这个主题。

这些批处理方法的主要目标是减少渲染当前视图中所有对象所需的绘制调用数量。在最基本的形式上,绘制调用是从 CPU 发送到 GPU 的请求,要求它绘制一个对象。

绘制调用是这一过程的行业通用术语,尽管在 Unity 中有时它们被称为 SetPass 调用,因为一些底层方法被这样命名。将其视为在启动当前渲染过程之前配置选项。我们将在这本书的其余部分中称其为绘制调用。

在请求绘制调用之前,系统需要执行几个操作。完整的列表太长,不适合这本书,并且取决于 Unity 上启用的特定功能;然而,我们可以将它们归类为两个重要的步骤:

  1. 将资产和网格上传到 GPU

  2. 使用上传的资产设置网格的渲染。

在第一步中,网格和纹理数据必须从 CPU 内存(RAM)推送到 GPU 内存(VRAM),这通常发生在场景初始化期间,但仅限于场景文件已知的纹理和网格。如果我们使用尚未出现在场景中的纹理和网格数据在运行时动态实例化对象,那么它们必须在实例化时加载。场景无法提前知道我们计划在运行时实例化的 Prefab,因为许多 Prefab 都隐藏在条件语句后面,而且我们应用程序的行为很大程度上取决于用户输入。

在第二步中,CPU 必须通过配置处理绘制调用目标对象所需的选项和渲染功能来准备 GPU。

为了处理 CPU 和 GPU 之间所有的这些交互,我们使用底层的图形 API,这可能是 DirectX、OpenGL、OpenGLES、Metal、WebGL 或 Vulkan,具体取决于我们针对的平台和使用的特定图形设置。这些 API 调用通过一个称为驱动程序的库进行,该库维护一系列复杂且相互关联的设置、状态变量和数据集,这些可以从我们的应用程序中进行配置和执行。可用的功能根据我们使用的图形卡和针对的图形 API 版本而有很大差异。多年来(尤其是对于像 DirectX 和 OpenGL 这样的旧 API)创建的设置、功能和兼容性级别之间的数量之多,简直令人难以置信。幸运的是,在一定的抽象级别上,所有这些 API 都倾向于以类似的方式运行,这意味着 Unity 可以通过一个通用接口支持许多不同的图形 API。

为了指代在渲染对象之前必须配置的这些庞大的设置数组,以便准备渲染管线,我们通常使用一个单一的术语:渲染状态。直到这些渲染状态选项保持不变,GPU 将保持最后设置的渲染状态,并相应地渲染所有传入的对象。

改变任何渲染状态设置可能是一个耗时的过程。例如,如果我们将渲染状态设置为使用蓝色纹理文件,然后我们尝试渲染一个巨大的网格,它将非常快速地渲染,整个网格看起来是蓝色的。在这个时候,我们可以渲染九个完全不同的网格,并且它们都会被渲染成蓝色,因为我们没有改变 GPU 在渲染状态中应该使用的纹理。如果我们想使用 10 种不同的纹理渲染 10 个网格,那么这将会花费更长的时间,因为我们需要在每个网格发送绘制调用指令之前,为每个网格准备带有新纹理的渲染状态。

用于渲染当前对象的纹理在图形 API 中实际上是一个全局变量,在并行系统中更改全局变量说起来容易做起来难。在一个如 GPU 这样的大规模并行系统中,我们必须有效地等待直到所有当前的工作都达到了相同的同步点(换句话说,最快的核心需要停止并等待最慢的追上来,浪费的处理器时间本可以用于其他任务)我们才能进行渲染状态更改,此时我们需要再次启动所有并行工作。这种持续的等待会浪费很多时间,因此我们越少要求渲染状态更改,图形 API 就越能快速处理我们的请求。

可以触发渲染状态同步的事物包括但不限于将新的纹理立即推送到 GPU、更改着色器、光照信息、阴影、透明度以及我们能够想到的几乎所有图形设置。

一旦我们配置了渲染状态,CPU 必须决定绘制哪个网格,使用哪些纹理和着色器,以及根据其位置、旋转和缩放(所有这些都在一个称为变换的 4 x 4 矩阵中表示,这也是Transform组件名称的由来)在哪里绘制对象,然后向 GPU 发送指令以绘制它。为了保持 CPU 和 GPU 之间的通信非常动态,Unity 会将新的指令推入一个称为命令缓冲区的队列。这个队列包含 CPU 创建的指令,GPU 在完成前一个指令后,会从中拉取一个新的命令。

批处理如何提高此过程性能的技巧在于,新的绘制调用并不一定意味着我们需要配置新的渲染状态。如果两个对象共享完全相同的渲染状态信息,那么 GPU 可以立即开始渲染新对象,因为最后一个对象完成后,相同的渲染状态仍然保持不变。这消除了由于渲染状态同步而浪费的时间。它还减少了需要推入命令缓冲区的指令数量,从而减少了 CPU 和 GPU 的工作负载。

材料和着色器

在 Unity 中,渲染状态主要通过材料向我们暴露。材料是着色器的容器,是一些简短的程序,它们定义了 GPU 应该如何渲染传入的顶点和纹理数据。单独的着色器本身并不具备完成任何有价值工作的必要状态知识。着色器需要输入,例如漫反射纹理、法线贴图和光照信息,并有效地指定需要设置哪些渲染状态变量以渲染传入的数据。

着色器之所以这样命名,是因为许多年前,它们的原始实现是仅处理对象的照明和着色(在原本没有阴影的地方应用阴影)。自那时以来,它们的目的已经极大地扩展了,现在它们有了一个更通用的目的,即作为许多不同类型并行任务的编程访问点,但旧的名字仍然保留。

每个着色器都需要一个材料,每个材料都必须有一个着色器。即使是新导入到场景中且未分配材料的网格,也会自动分配一个默认(隐藏)材料,这会给它们一个基本的漫反射着色器和白色着色,因此无法绕过这种关系。

注意,单个材料只能支持单个着色器。在同一个网格上使用多个着色器需要为网格的不同部分分配不同的材料。

因此,如果我们想最小化渲染状态变化的频率,那么我们可以通过减少场景中使用的材质数量来实现。这将同时带来两个性能提升:CPU 在每一帧中将花费更少的时间生成和传输指令到 GPU,GPU 也不需要频繁地停止和重新同步状态变化。

让我们从简单的场景开始,以便可视化材质和批处理的行为。然而,在我们开始之前,我们应该禁用一些渲染选项,因为它们可能会增加额外的绘制调用,这可能会分散注意力:

  1. 导航到编辑 | 项目设置 | 质量,将阴影设置为禁用阴影(或选择默认的最快质量级别)

  2. 导航到编辑 | 项目设置 | 玩家,打开其他设置选项卡,如果已启用,禁用静态批处理和动态批处理

接下来,我们将创建一个包含单个方向光、四个立方体和四个球体的场景,其中每个对象都有其独特的材质、位置、旋转和缩放,如图所示:

在前面的截图中,我们可以在游戏窗口的“统计”弹出窗口中的“批处理”值中看到 9 个总批次数。这个值紧密地代表了渲染场景所使用的绘制调用次数。当前视图将消耗这些批次数中的一个,用于渲染场景的背景,这可以是设置为 Skybox 或纯色。这是由相机对象的清除标志设置决定的。

剩下的八个批次数用于绘制我们的八个对象。在每种情况下,绘制调用都涉及到使用材质的属性准备渲染管线,并要求 GPU 在当前变换下渲染给定的网格。我们通过为每个材质提供一个独特的纹理文件来渲染,确保每个材质都是唯一的。因此,每个网格需要不同的渲染状态,因此我们八个网格中的每一个都需要独特的绘制调用。

如前所述,我们可以通过减少系统更改渲染状态信息的频率来理论上最小化绘制调用次数;因此,部分目标是减少我们使用的材质数量。然而,如果我们将所有对象配置为使用相同的材质,我们仍然不会看到任何好处,批次数将保持在九个:

这是因为我们实际上并没有减少渲染状态变化的次数,也没有有效地分组网格信息。不幸的是,渲染管线并不足够智能,无法意识到我们在覆盖相同的渲染状态值,然后要求它反复渲染相同的网格。

帧调试器

在我们深入探讨批处理如何帮助我们节省绘制调用之前,让我们探索一个有用的工具,它可以帮助我们确定批处理如何影响我们的场景——帧调试器。

我们可以通过从主窗口中选择“窗口”|“分析”|“帧调试器”,或者在 Profiler 的渲染区域中的“分解视图选项”中点击帧调试器按钮来打开 Frame Debugger。两种方法都会打开帧调试窗口。

在帧调试窗口中点击启用按钮将允许我们逐个观察场景的构建过程。以下截图显示了帧调试器的用户界面,左侧面板列出了 GPU 指令,右侧面板提供了更详细的信息:

图片

在此窗口中有很多信息可以提供给我们有用的信息,如果我们想调试单个绘制调用的行为,但最有用的区域是左侧面板中的绘图部分,它列出了场景中的所有绘制调用。

本节中的每个项目代表一个独特的绘制调用以及它所渲染的内容。这个工具的一个非常实用的功能是能够点击这些项目中的任何一个,并立即在游戏窗口中观察到渲染到该点的场景所需的绘制调用。这让我们可以直观地看到两个连续绘制调用之间的差异。这可以让我们轻松地确定哪些对象被特定的绘制调用所渲染。这有助于通过查看在绘制调用期间出现的对象数量来确定是否将一组对象批量处理在一起。

Frame Debugger(帧调试器)中存在的一个奇怪的问题(在 Unity 2019 的早期构建中仍然存在)是,如果我们正在观察一个使用天空盒的场景,并在绘图部分点击各种项目,那么在游戏窗口中只能观察到最终的场景呈现。我们需要通过将摄像机的清除标志设置暂时禁用天空盒,将其设置为纯色,来查看在游戏窗口中绘制调用进展的情况。

如前所述的帧调试器截图所示,一个绘制调用用于清除屏幕(标记为清除的项目),然后我们的八个网格在八个单独的绘制调用中渲染(标记为RenderForward.RenderLoopJob的项目)。

注意,左侧面板中每个项目的数字实际上代表一个图形 API 调用,其中绘制调用只是 API 调用的一种类型。这些可以在Camera.RenderCamera.ImageEffectsRenderTexture.ResolveAA项目中看到。任何 API 调用都可能像绘制调用一样昂贵,但我们在复杂场景中将要进行的绝大多数 API 调用都将采用绘制调用的形式,因此通常最好在担心诸如后期处理效果之类的 API 通信开销之前,先关注最小化绘制调用。

动态批量处理

动态批量处理具有以下三个重要特性:

  • 批量在运行时生成(批量是动态生成的)

  • 批处理中的对象可能从一帧到下一帧变化,具体取决于当前对主摄像机视图可见的网格(批处理内容是动态的)。

  • 即使可以在场景中移动的对象也可以批处理(它适用于动态对象)。

这些属性使我们得出了动态批处理的名字。

如果我们回到玩家设置页面并启用动态批处理,我们应该看到批处理数量从九个下降到六个。动态批处理自动识别我们的对象共享材质和网格信息,因此将其中一些合并成更大的批处理以进行处理。我们还应该在帧调试器中看到不同的项目列表,表明网格现在正在动态批处理:

图片

如我们从帧调试器中看到的那样,我们的四个盒子已被合并成一个名为动态批处理的单个绘制调用,但我们的四个球体仍然使用四个单独的绘制调用进行渲染。这是因为四个球体不符合动态批处理的要求,尽管它们都使用了相同的材质。我们还有许多其他要求必须满足。

您可以在 Unity 文档中找到成功动态批处理网格所需的要求列表,地址如下:docs.unity3d.com/Manual/DrawCallBatching.html

以下列表涵盖了为给定网格启用动态批处理的要求:

  • 所有网格实例必须使用相同的材质引用。

  • 只有ParticleSystemMeshRenderer组件可以动态批处理。SkinnedMeshRenderer组件(用于动画角色)和所有其他可渲染组件类型不能批处理。

  • 每个网格的顶点数限制为 300 个;然而,着色器使用的总顶点属性数不得超过 900 个。这意味着对于复杂的着色器,每个网格的最大顶点数可能少于 300 个(有关更多详细信息,请参阅顶点属性部分)。

  • 对象不得在变换上包含镜像(即,具有正缩放的GameObject A 和具有负缩放的GameObject B 不能一起批处理)。

  • 网格实例应引用相同的光照贴图文件。

  • 材料的着色器不应依赖于多个遍历。

  • 网格实例不得接收实时阴影。

  • 整个批处理中网格索引的总数有一个上限,这取决于所使用的图形 API 和平台,大约为 32,000-64,000 个索引(具体信息请参阅文档/之前提到的博客文章)。

需要注意的是,查看网格的原始数据文件可能包含的顶点属性信息少于 Unity 加载到内存中的信息,因为引擎将网格数据从几种原始数据格式之一转换为内部格式的方式。因此,不要假设我们的 3D 建模工具告诉我们的网格使用的属性数量将是最终的数量。验证属性数量的最佳方法是钻入项目窗口中的网格对象,直到找到MeshFilter组件,然后在检查器的预览子部分中查看出现的 verts 值。

顶点属性

顶点属性简单来说就是包含在网格文件中的每顶点信息,每个通常表示为一组多个浮点值。这包括但不限于顶点的位置(相对于网格的根),法线向量(一个指向物体表面的向量,通常用于光照计算),一组或更多纹理 UV 坐标(用于定义一个或多个纹理如何包裹网格),以及可能还有每个顶点的颜色信息(通常用于自定义光照或用于平面着色、低多边形风格的对象)。只有使用少于 900 个总顶点属性的网格才能包含在动态批处理中。

注意术语材质引用,因为如果我们恰好使用了两个设置相同的不同材质,渲染管线并不足够智能以意识到这一点,它们将被视为不同的材质,因此将无法参与动态批处理。其余的大部分要求已经解释过了;然而,其中一些要求并不完全直观或从其描述中不清楚,这需要一些额外的解释。

在伴随的着色器中使用每个顶点更多的属性数据将消耗我们 900 个属性预算中的一部分,因此会减少网格在无法再用于动态批处理之前允许拥有的顶点数量。例如,一个简单的漫反射着色器可能每个顶点只使用三个属性:位置、法线和一组 UV 坐标。因此,动态监控将能够支持使用此着色器的网格,该着色器总共包含 300 个顶点;然而,一个更复杂的着色器,每个顶点需要 5 个属性,将只能支持使用不超过 180 个顶点的网格进行动态批处理。此外,请注意,即使我们在着色器中每个顶点使用少于 3 个属性,动态批处理仍然只支持最大 300 个顶点的网格,因此只有相对简单的对象才适合动态批处理。

这些限制是为什么即使所有对象共享相同的材质引用,我们的场景在启用动态批处理的情况下也只保存了 3 个绘制调用。Unity 自动生成的立方体网格仅包含 8 个顶点,每个顶点都有位置、法线和 UV 数据,总共 24 个属性。这远低于 300 个顶点的限制和 900 个顶点属性的限制。然而,自动生成的球体网格包含 515 个顶点,因此总共有 1,545 个顶点属性。这些网格显然超过了 300 个顶点和 900 个顶点属性的限制,因此不能进行动态批处理。

图片

如果我们在帧调试器中点击其中一个绘制调用项,会出现一个标记为“为什么这个绘制调用不能与上一个一起批处理”的部分。大多数情况下,下面的解释文本会告诉我们我们未满足哪些要求(或者至少是它检测到的第一个)以及哪些可能对调试批处理行为有用。

网格缩放

文档明确指出,使用负缩放对动态批处理有奇怪的影响。负缩放通常是快速镜像场景中网格的一种方法,这可以让我们避免为仅在一个轴上翻转的对象创建和导入一个完全不同的网格。这个技巧通常用于一对门,或者只是为了使场景看起来更加多样化。然而,如果我们只在一个或三个轴上负缩放网格,那么它将被放入与在零或两个轴上负缩放的网格不同的动态批处理中。三个值(xyz)中哪个是负的并不重要,重要的是负值的总数是奇数还是偶数。

批量拆分在幕后工作的另一个奇怪特性是,对象的渲染顺序可以决定哪些对象会被一起批处理。如果上一个对象原本会出现在与当前不同的批处理组中,那么它就不能被批处理。再次强调,这最好通过一个例子来解释。假设我们再次有五个对象:V 缩放为 (1, 1, 1)W 缩放为 (-1, 1, 1)X 缩放为 (-1, -1, 1)Y 缩放为 (-1, -1, -1),最后 Z 缩放与 V 相似,为 (1, 1, 1)。对象 VZ 具有共同的统一缩放,所以我们可能期望它们会被一起批处理。然而,如果所有这些对象都按照前面的顺序渲染到场景中,那么对象 V 将会被渲染,Unity 将会测试对象 WV 是否可以共享一个批处理。它们不能,因为对象 W 的奇数负缩放,所以不会进行批处理。然后 Unity 将比较对象 X 与对象 W,以检查它们是否可以批处理,但它们不能,因为 W 有奇数负缩放而 X 有偶数负缩放。对象 W-YY-Z 之间的后续比较也会因为相同的原因失败。最终结果是,所有五个对象都将使用五个单独的绘制调用进行渲染,并且没有机会将对象 VZ 合并。请注意,这种奇怪的效果仅在使用负缩放时才会出现。

很可能,这全是检测有效批处理组所使用的算法的副产品,因为将网格在二维中镜像在数学上等同于围绕镜像轴旋转网格 180 度,而将网格在一维或三维上镜像没有旋转的等价物。因此,我们观察到的行为可能是动态批处理系统自动为我们转换对象,尽管这并不完全清楚。无论如何,希望这为我们准备了许多我们可能在生成动态批处理时遇到的各种奇怪情况。

动态批处理总结

当我们想要渲染大量简单的网格时,动态批处理是一个非常有用的工具。系统的设计使其在大量使用几乎外观相同的简单网格时变得理想。动态批处理的一些可能的用例可能如下:

  • 我们希望渲染一个充满岩石、树木和灌木的大型森林

  • 我们希望渲染一个由许多简单、常见元素(如电脑、走廊部件、管道等)组成的建筑、工厂或太空站

  • 我们希望构建一个包含许多动态、非动画对象的游戏,这些对象具有简单的几何形状和粒子效果(例如,会想到 Geometry Wars 这样的游戏)

如果阻止两个对象动态批处理在一起的唯一要求是它们使用不同的纹理文件,请注意,只需一点开发时间和努力,就可以合并纹理并重新生成网格 UV,以便它们可以一起动态批处理(通常称为图集化)。这可能会影响纹理质量或纹理文件的整体大小(我们将在第六章“动态图形”中深入了解 GPU 内存带宽时理解其缺点),但这值得考虑。

可能唯一可能阻碍性能的动态批处理情况是,如果我们设置了一个包含数百个简单对象的场景,其中每个批次只包含几个对象。在这些情况下,检测和生成如此多的小批次的开销可能会比我们通过为每个网格单独进行绘制调用所节省的时间更多。即便如此,这种情况也不太可能发生。

如果说有什么的话,我们更有可能因为忘记了一个基本要求而简单地假设动态批处理正在进行,从而对我们的应用程序造成性能损失。我们可能会通过推送网格的新版本意外地突破顶点限制,在 Unity 将原始对象(具有.obj扩展名)文件转换为它自己的内部格式的过程中,它会产生比我们预期的更多的顶点属性。我们也可能通过调整一些着色器代码或添加额外的通道而超出限制,而没有意识到这将使其不符合动态批处理的要求。我们甚至可能设置对象以启用阴影或光照探针,这又违反了另一个要求。

当这些意外发生时,除了更改后绘制调用次数增加,导致图形性能进一步下降之外,不会有任何警告信号。在我们的场景中保持健康的动态批处理数量需要我们持续关注我们的绘制调用次数,并查看帧调试器数据,以确保我们没有在最近的变化中意外地使对象不符合动态批处理的要求。然而,正如往常一样,我们只需要担心我们的绘制调用性能,如果我们已经证明它正在造成性能瓶颈。

最终,每种情况都是独特的,因此尝试我们的网格数据、材料和着色器以确定哪些可以动态批处理,哪些不可以,是非常有价值的。同时,时不时地对我们的场景进行测试,以确保我们使用的绘制调用次数保持合理。

静态批处理

Unity 提供了一种名为静态批处理的第二个批处理机制。这种批处理功能在几个方面与动态批处理相似,即要批处理的对象是在运行时根据相机可见的内容确定的,并且这些批次的内含物会随帧而变化。然而,有一个非常重要的区别:它只适用于标记为静态的对象,因此得名静态批处理。

静态批处理系统有其自己的要求:

  • 如其名所示,网格必须被标记为静态(具体来说,是批处理静态)。

  • 必须为每个正在静态批处理的网格预留额外的内存。

  • 在静态批处理中可以组合的顶点数有一个上限,这个上限因图形 API 和平台而异,大约是 32,000-64,000 个顶点(具体信息请查看文档/之前提到的博客文章)。

  • 网格实例可以来自任何源网格,但它们必须共享相同的材质引用。

让我们更详细地介绍一些这些要求。

静态标记

静态批处理只能应用于启用了静态标记的对象,或者更具体地说,是批处理静态子标记(这些子标记被称为 StaticEditorFlags)。点击GameObject旁边的静态选项旁边的小向下箭头将显示一个下拉列表,其中包含 StaticEditorFlags,可以改变对象在静态过程中的行为。

这种情况的明显副作用是,对象的变换不能改变,因此,任何希望使用静态批处理的对象都不能移动、旋转或缩放。

内存需求

静态批处理所需的额外内存量将根据批处理网格中复制的数量而变化。静态批处理通过将所有标记和可见网格的数据复制到一个单独的大型网格数据缓冲区中,并通过单个绘制调用将其传递到渲染管线,同时忽略原始网格来实现。如果所有静态批处理的网格都是唯一的,那么与正常渲染对象相比,这不会给我们带来额外的内存使用,因为存储网格所需的内存空间相同。

然而,由于数据实际上是复制的,这些静态批处理的副本给我们带来了额外的内存,相当于网格数量的倍数乘以原始网格的大小。通常,渲染一个、十个或一百万个相同对象的克隆给我们带来的内存使用量是相同的,因为它们都引用相同的网格数据。在这种情况下,对象之间的唯一区别是每个对象的变换;然而,由于静态批处理需要将数据复制到大型缓冲区中,这种引用就丢失了,因为原始网格的每个副本都复制到缓冲区中,并带有独特的数据集,其中硬编码的变换被嵌入到顶点位置中。

因此,使用静态批处理渲染 1,000 个相同的树对象,将比不使用静态批处理渲染相同的树消耗 1,000 倍多的内存。如果静态批处理使用不当,这会导致一些显著的内存消耗和性能问题。

材质引用

我们已经知道,共享材质引用是减少渲染状态变化的一种方法,因此这个要求相当明显。此外,有时我们会静态批处理需要多个材质的网格。在这种情况下,使用不同材质的所有网格将分别组成自己的静态批处理,以适应每个独特的材质。

这个要求的缺点是,在最理想的情况下,静态批处理只能使用与所需材质数量相等的绘制调用来渲染所有静态网格。

静态批处理注意事项

由于它处理批处理解决方案的方式(通过将网格组合成单个更大的网格),静态批处理系统有一些需要注意的问题。这些问题从轻微的不便到严重的缺点不等,具体取决于场景:

  • 从统计窗口中,直到运行时才能看到绘制调用节省的效果

  • 在运行时场景中引入标记为批处理静态的对象不会自动包含在静态批处理中

让我们更详细地探讨这些问题。

静态批处理的编辑模式调试

尝试确定静态批处理对我们场景的整体影响可能会有些棘手,因为在编辑模式下并没有进行任何静态批处理。所有的魔法都是在运行时发生的,这使得在没有手动测试的情况下很难确定静态批处理能提供哪些好处。我们应该使用帧调试器来验证我们的静态批处理是否被正确生成,并且它们是否包含预期的对象。

如果我们直到项目生命周期的后期才实现这个功能,这可能会特别有问题,那时我们可能会花费大量时间启动、调整和重新启动场景,以确保我们得到预期的绘制调用节省。因此,最好在构建新场景的过程中尽早开始静态批处理优化。

不言而喻,静态批处理创建工作并非完全微不足道,如果需要创建许多批处理以及/或许多大型对象进行批处理,这也可能极大地增加场景初始化时间。

在运行时实例化静态网格

在运行时添加到场景中的任何新对象都不会自动被静态批处理系统合并到任何现有的批处理中,即使它们被标记为批处理静态。这样做会导致在重新计算网格和与渲染管线同步之间产生巨大的运行时开销,因此 Unity 甚至不会尝试自动执行。

在大多数情况下,我们应该尝试将我们希望进行静态批处理的任何网格保留在原始场景文件中;然而,如果需要动态实例化,或者我们正在使用额外的场景加载,那么我们可以使用StaticBatchUtility.Combine()方法来控制静态批处理资格。这个实用方法有两个重载:要么我们提供一个根GameObject,在这种情况下,所有具有网格的子GameObject实例都将被转换为新的静态批处理组,要么我们提供一个GameObject实例列表和一个根GameObject,它将自动将它们作为子对象附加到根对象,并以相同的方式生成新的静态批处理组。

我们应该分析这个函数的使用情况,因为它在需要合并许多顶点时可能相当昂贵。它也不会将给定的网格与任何现有的静态批处理组合并,即使它们具有相同的材质。这意味着我们无法通过实例化或以累加方式加载使用与场景中已存在的静态批处理组相同材质的静态网格来节省绘制调用(它只能与在Combine()调用中分组在一起的网格合并)。

注意,如果我们使用StaticBatchUtility.Combine()方法批处理的对象中任何一个在批处理之前没有被标记为静态,那么这些对象将保持非静态,但网格本身将是静态的。这意味着我们可能会意外地移动GameObject实例、其Collider组件以及任何其他重要对象,但网格将保持在同一位置。在静态批处理对象中,小心不要意外地将静态和非静态状态混合。

静态批处理摘要

静态批处理是一个强大但危险的工具。如果我们不谨慎使用,我们很容易因为内存消耗(可能导致应用程序崩溃)和渲染成本而造成巨大的性能损失。它还需要大量的手动调整和配置,以确保批处理被正确生成,并且我们没有意外引入使用各种静态标志的不希望出现的副作用。然而,它确实有一个显著的优势,即它可以用于不同形状和巨大尺寸的网格,这是动态批处理无法提供的。

摘要

很明显,动态批处理和静态批处理系统并不是万能的解决方案。我们不能盲目地将它们应用到任何给定的场景中并期望得到改进。如果我们的应用程序和场景恰好符合一组特定的参数,那么这些方法在减少 CPU 负载和渲染瓶颈方面非常有效。然而,如果它们不符合,那么我们需要做一些额外的工作来准备我们的场景以满足批处理功能的要求。最终,只有对这些批处理系统及其工作方式有良好的理解,我们才能确定在哪里以及何时应用这个功能,而且,希望这一章已经为我们提供了做出明智决策所需的所有信息。

你将在第六章“动态图形”中学习更多关于渲染管线和性能提升技术。但现在,让我们转向一个不同的主题,探讨一些通过智能管理我们的艺术资产所能实现的更微妙性能提升。

第四章:优化您的艺术资产

艺术是一个著名的具有主观性的学科,由个人意见和偏好主导。很难说一件艺术品是否比另一件更好,以及为什么更好。很多时候,我们的意见无法达成完全一致。支持游戏艺术性的艺术资产背后的技术方面也可能非常主观。可以实施多种解决方案来提高性能,但这些通常会导致为了速度而牺牲质量。如果我们试图达到最佳性能,那么在决定对艺术资产进行任何更改时,我们必须咨询团队成员,因为这主要是一个平衡行为,这本身也可以是一种艺术形式。

无论我们是在尝试最小化运行时内存占用、保持尽可能小的可执行文件大小、最大化加载速度,还是保持帧率的稳定性,都有许多选项可以探索。一些方法显然总是理想的,但大多数方法在采用之前需要更多的关注和预先思考,因为它们可能会导致质量下降或增加其他子系统出现瓶颈的可能性。

在本章中,我们将探讨如何提高以下资产类型的性能:

  • 音频文件

  • 纹理文件

  • 网格和动画文件

  • 资产包和资源

在每种情况下,我们将研究 Unity 在应用构建时间和运行时如何存储、加载和处理这些资产。我们还将检查在性能问题发生时的选项,以及我们可以采取哪些措施来避免可能产生性能瓶颈的行为。

音频

作为一种框架,Unity 可以用来构建任何东西,从只需要少量音效和一条背景音乐的简单应用,到需要数百万行对话、音乐轨道和环境音效的庞大角色扮演游戏。无论应用的实际范围如何,音频文件在构建完成后通常都是应用大小的一个重要组成部分(有时也称为其 磁盘占用)。此外,许多开发者惊讶地发现,运行时音频处理可以变成 CPU 和内存消耗的一个重大来源。

音频在游戏行业的两个层面都常常被忽视:开发者往往直到最后一刻才投入资源,而用户很少关注它。当音频处理得当的时候,没有人会注意到,但我们都知道糟糕的音频是什么样的——它立刻就能辨认出来,令人不快,并且肯定会引起不必要的注意。这使得在性能的名义上牺牲过多的音频清晰度变得至关重要。

音频瓶颈可能来自各种来源。过度的压缩、过多的音频处理、过多的活跃音频组件、不高效的内存存储方法以及访问速度都会导致内存和 CPU 性能下降。

幸运的是,只需一点努力和理解,你就可以学会避免这些问题。在接下来的章节中,我们将学习一些有用的技巧,以帮助我们避免用户体验灾难。我们将学习如何在不同音频加载选项中进行选择,如何为我们的游戏选择正确的音频格式,以及一些其他相关的性能调整。

导入音频文件

当我们在项目窗口中选择导入的音频文件时,检查器窗口将显示多个导入设置。这些设置决定了从加载行为、压缩行为、质量、采样率,以及(在 Unity 的后续版本中)是否支持环绕声音频(多声道音频,通过球谐函数组合轨道以创建更真实的音频体验)等各个方面。

许多音频导入选项可以根据平台进行配置,使我们能够在不同的目标平台之间自定义行为。

加载音频文件

以下是如何加载音频文件的三种设置:

  • 预加载音频数据

  • 在后台加载

  • 加载类型

图片

在检查器中查看导入文件时,我们所看到的内容。

我们的音频文件最初是以二进制数据文件的形式打包的,这些文件与应用程序捆绑在一起,位于设备的硬盘上(尽管在某些情况下它们是从互联网上的某个地方下载的)。加载音频数据简单地说就是将其拉入主内存(RAM),以便它可以稍后由音频解码器处理,解码器然后将数据转换为音频信号,传递到我们的耳机或扬声器。然而,加载的方式将根据前三个设置而有很大差异,如下所示:

  • 第一个设置,预加载音频数据,决定了音频数据是否会在场景初始化期间或稍后自动加载。

  • 当音频数据的加载发生时,第二个设置,在后台加载,决定了这项活动是否会在完成之前阻塞主线程,或者异步地在后台加载。

  • 最后,加载类型设置定义了将哪种类型的数据拉入内存,以及每次拉入多少数据。

如果使用不当,这三个设置都可能对性能产生严重影响。

音频文件的典型用途是将它分配给AudioSource组件的AudioClip属性,这将把它包裹在一个AudioClip对象中。然后我们可以通过AudioSource.Play()AudioSource.PlayOneShot()触发播放。以这种方式分配的每个音频剪辑都会在场景初始化期间加载到内存中,因为场景包含对这些文件的直接引用,它必须在需要之前解决这些引用。这是在启用预加载音频数据时的默认行为。

禁用“预加载音频数据”会告诉 Unity 引擎在场景初始化期间跳过音频文件资产的加载,将加载活动推迟到第一次需要的时候——即调用Play()PlayOneShot()时。禁用此选项将加快场景初始化速度,但这也意味着我们第一次播放文件时,CPU 需要立即访问磁盘,检索文件,将其加载到内存中,解压缩它,然后播放。这是一个同步操作,并且将在完成之前阻塞主线程。我们可以通过一个简单的测试来证明这一点:

public class PreloadAudioDataTest : MonoBehaviour {
  [SerializeField] AudioSource _source;

  void Update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
        using (new CustomTimer("Time to play audio file", 1)) {
        _source.Play();
    }
  }
}

如果我们在场景中添加一个AudioSource对象,将其分配给一个大型音频文件,并将其分配给PreloadAudioDataTest组件的_source字段,我们可以按空格键并查看Play()函数完成所需的时间。对这个代码进行一个简单的测试,使用一个 10-MB 的音频文件并启用预加载音频数据,将显示调用几乎是瞬间的;然而,禁用预加载音频数据,应用对文件所做的更改,并重复测试将显示它需要更长的时间(在配备 Intel i5 3570K 的桌面 PC 上大约为 700 毫秒)。这完全超出了我们单帧的预算,因此为了负责任地使用这个切换,我们需要在事先将大部分音频资产加载到内存中。

这可以通过调用AudioClip.LoadAudioData()(可以通过AudioSource组件的clip属性获取)来实现。然而,这项活动仍然会阻塞主线程,所需的时间与上一个例子中加载它的时间相同,因此无论我们选择提前加载还是不加载,加载我们的音频文件仍然会导致帧率下降。数据也可以通过AudioClip.UnloadAudioData()卸载。

这就是“在后台加载”选项的作用所在。这个选项将音频加载改为异步任务,这意味着加载不会阻塞主线程。启用此选项后,对AudioClip.LoadAudioData()的实际调用将立即完成,但请注意,文件将在单独的线程上加载完成后才能播放。我们可以通过AudioClip.loadState属性来双重检查AudioClip组件当前的加载状态。如果启用“在后台加载”,并且我们在没有先加载数据的情况下调用AudioSource.Play(),Unity 仍然需要在播放之前将文件加载到内存中,因此当我们调用AudioSource.Play()和音频文件开始播放之间会有延迟。如果我们试图在文件完全加载之前访问声音文件,这可能会引入令人不快的操作,导致它与其他任务(如动画)不同步。

现代游戏通常在关卡中实现方便的停止点以执行加载或卸载音频数据等任务——例如,楼层之间的电梯或发生最小动作的长走廊。涉及通过这些方法进行自定义加载和卸载音频数据解决方案需要根据特定游戏量身定制,具体取决于何时需要音频文件、需要多长时间、场景如何组合以及玩家如何穿越它们。

这可能需要大量的特殊情况更改、测试和资产管理调整,因此建议您将此方法保存为终极选项,在所有其他技术未能达到预期效果时,在生产后期使用。

最后,还有加载类型选项,它决定了音频数据加载时的方式。有三个选项可供选择:

  • 加载时解压缩

  • 内存中压缩

  • 流式传输

以下列表中详细解释了这三个选项:

  • 加载时解压缩:此设置在磁盘上压缩文件以节省空间,并在首次加载时将其解压缩到内存中。这是加载音频文件的标准方法,在大多数情况下应使用。解压缩文件需要一些时间,这会导致加载时产生一些额外的开销,但减少了播放音频文件时所需的工作量。

  • 压缩内存中:此设置在加载时直接将压缩文件从磁盘复制到内存中。它仅在运行时播放音频文件时进行解压缩。当音频剪辑播放时,这会牺牲运行时的 CPU 资源,但可以提高加载速度并减少音频剪辑处于休眠状态时的运行内存消耗。因此,此选项最适合经常使用的非常大的音频文件,或者如果我们对内存消耗有极大的瓶颈,并且愿意牺牲一些 CPU 周期来播放音频剪辑。

  • 流式传输:最后,这个设置(也称为缓冲)将在运行时动态加载、解码和播放文件,通过逐渐将文件推入一个只有一小部分文件在内存中存在的小缓冲区。这种方法对特定音频剪辑使用的内存最少,但运行时 CPU 使用量最大。由于每个文件的播放实例都需要生成其缓冲区,因此这个设置带来了不幸的缺点,即需要多次引用音频剪辑,导致内存中存在多个相同的音频剪辑副本,这些副本都必须单独处理,从而在使用不当的情况下产生运行时 CPU 成本。因此,此选项最好保留用于单实例音频剪辑,这些音频剪辑经常播放,并且永远不会需要与其他实例或甚至与其他流式传输音频剪辑重叠——例如,此设置最好用于在场景大部分生命周期内需要播放的背景音乐和环境音效。

因此,让我们回顾一下。默认情况下,启用预加载音频数据,禁用后台加载,以及加载类型为“加载时解压缩”,会导致场景加载时间较长,但确保我们在需要时场景中引用的每个音频剪辑都立即准备好。当需要音频剪辑时,不会有加载延迟,并且音频剪辑将在我们调用Play()时立即播放。

为了提高场景加载时间,一个良好的折衷方案是对于之后才需要的音频剪辑启用“在后台加载”,但不应为此类在场景初始化后不久就需要音频剪辑的情况使用。然后我们可以通过手动调用AudioClip.LoadAudioData()AudioClip.UnloadAudioData()来控制音频数据的加载时间。我们应该愿意在单个场景中使用所有这些方法以达到最佳性能。

编码格式和质量级别

Unity 支持三种通用的音频剪辑编码格式,这些格式由我们在检查器窗口中查看音频剪辑属性时的“压缩格式”选项确定:

  • 压缩(此选项的实际文本可能因平台而异)

  • PCM

  • ADPCM

我们导入 Unity 引擎的音频文件可以是许多流行的音频文件格式之一,如 Ogg Vorbis、MPEG-3(MP3)和 Wave,但实际打包到可执行文件中的编码将被转换为不同的格式。

使用压缩设置的压缩算法将取决于目标平台。独立应用程序和其他非移动平台将文件转换为 Ogg Vorbis 格式,而移动平台使用 MP3。

有些平台始终使用特定的压缩类型,例如 PS Vita 的 HEVAG、Xbox One 的 XMA 和 WebGL 的 AAC。

统计数据在“检查器”窗口中提供,位于“压缩格式”选项之后,为您提供了压缩节省磁盘空间的大致想法。请注意,第一个值显示原始文件大小,第二个值显示磁盘上的大小。音频文件在加载后运行时将消耗多少内存将由所选压缩格式的效率决定——例如,Ogg Vorbis 压缩通常可以解压缩到其压缩大小的约十倍,而 ADPCM 可以解压缩到其压缩大小的约四倍。

检查器窗口中显示的音频文件成本节省仅适用于当前选定的平台和最近使用的设置。请确保在“文件”|“构建设置”中将编辑器切换到正确的平台,并在更改后点击“应用”,以查看当前配置的实际成本节省(或成本膨胀)。这对于 WebGL 应用程序尤为重要,因为 AAC 格式通常会导致音频文件大小大幅膨胀。

所使用的编码/压缩格式对音频文件在运行时的质量、文件大小和内存消耗有显著影响,只有压缩设置允许我们在不影响文件采样率的情况下调整质量。同时,PCM 和 ADPCM 设置不提供这种便利,我们只能接受这些压缩格式决定的文件大小——也就是说,除非我们愿意为了文件大小而降低音频质量,减少采样率。

在下表中,您可以快速了解每种格式的区别和使用场景:

格式 无损 大小 质量 用途
PCM 非常短的声音效果,需要很高的清晰度,任何压缩都会扭曲体验。
ADPCM 非常小 压缩会产生相当多的噪声,因此它用于具有大量混乱的短声音效果,例如爆炸、碰撞和冲击声。
压缩 小/中 可变 这会消耗更多的 CPU 解码资源,因此在大多数情况下应使用。此选项允许我们自定义压缩算法的结果质量级别,以调整质量与文件大小的平衡。

请记住,在运行时应用于文件的任何额外音频效果在编辑模式中都不会通过编辑器播放,因此任何更改都应该通过播放模式中的应用程序彻底测试。

现在我们对音频文件格式、加载方法和压缩模式有了更好的理解,让我们探讨一些可以通过调整音频行为来提高性能的方法。

音频性能提升

在本节中,我们将探讨一些其他的小但重要的增强功能,您可以将它们添加到游戏的声音架构中,以改善整体玩家体验。我们将了解为什么在场景中减少音频源很重要,在什么情况下我们应该优先选择单声道声音而不是立体声音响,我们应该在什么情况下优先选择流式传输而不是预加载,以及更多。

最小化活动音频源数量

由于每个正在播放的音频源都会消耗特定的 CPU 资源,因此我们可以通过在场景中禁用冗余音频源来节省 CPU 循环。一种方法是通过一个中介发送音频播放请求,该中介以这种方式控制我们的音频源,从而对可以同时播放的音频剪辑实例数设置一个硬上限。

几乎在 Unity Asset Store 中可用的所有音频管理资源都实现了某种形式的音频节流功能(通常称为 音频池化),这有很好的理由:这是在最小化过度音频播放的同时,以最低的质量成本进行权衡的最佳方案——例如,同时播放 20 个脚步声听起来不会与同时播放 10 个有太大区别,并且不太可能因为声音太大而分散注意力。因此,出于这个原因,并且因为这些工具通常提供许多更细微的性能增强功能,建议您使用现有的解决方案,而不是自己开发,因为需要考虑的复杂性很多,包括音频文件类型、立体声/3D 音频、分层、压缩、过滤器、跨平台能力、高效内存管理等等。

当涉及到环境音效时,它们仍然需要放置在场景中的特定位置,以便利用对数音量效果,这给它带来一种伪 3D 效果,因此音频池化系统可能不是理想的解决方案。限制环境音效的播放最佳方法是通过减少音频源的总数。最佳方法是移除其中一些,或将它们减少到一个更大、更响亮的音频源。自然地,这种方法会影响用户体验的质量,因为看起来声音似乎来自单个源而不是多个源;因此,应谨慎使用。

为 3D 声音启用强制单声道

在立体声音频文件上启用“强制单声道”设置会将两个音频通道的数据混合到一个通道中,从而有效地节省 50%的文件总磁盘和内存空间使用量。对于一些立体声音效,其中立体声效果通常用于创造特定的音频体验,启用此选项通常不是一个好主意;然而,我们可以为一些 3D 位置音频剪辑启用此选项,在这些剪辑中,两个通道实际上是相同的。这些音频源类型将允许音频源和玩家之间的方向决定音频文件如何播放到左右耳朵,在这种情况下播放立体声效果通常是没有意义的。

如果不需要立体声效果,将 2D 声音(无论距离/方向如何,都以全音量播放到玩家的耳朵中的声音)强制转换为单声道可能也有意义。

重采样到更低频率

将导入的音频文件重采样到更低频率将减小文件大小和运行时内存占用。这可以通过将音频文件的采样率设置设置为“覆盖采样率”来实现,此时我们可以通过采样率选项配置采样率。一些文件需要高采样率才能听起来合理,例如高音调文件和大多数音乐文件;然而,在大多数情况下,较低的设置可以减小文件大小,而不会引起明显的质量下降。大多数使用 22,050 赫兹的采样率用于涉及人类语音和古典音乐的源;一些音效可能能够以更低的频率值逃脱。然而,每个音效都会以独特的方式受到此设置的影响,因此在最终决定采样率之前,花一些时间进行一些测试是明智的。

考虑所有压缩格式

如前所述,压缩、PCM 和 ADPCM 压缩格式各自都有其优势和劣势。根据不同文件的需要,使用不同的编码格式可以在内存占用、磁盘占用、CPU 使用率和音频质量方面做出一些妥协。我们应该愿意在同一个应用程序中使用所有这些格式,并制定一个适用于我们使用的音频文件类型的系统,这样我们就不需要单独处理每个文件;否则,我们需要进行大量的测试以确保每个文件的音频质量没有下降。

谨慎对待流媒体

流式加载类型的优点是运行时内存成本低,因为分配了一个小的缓冲区,文件就像数据队列一样连续通过它。这看起来相当吸引人,但应该仅将磁盘上的流式文件限制在大型单实例文件上,因为它需要运行时硬盘访问,这是我们可用的最慢的数据访问形式之一(仅次于通过网络拉取文件)。使用流式选项,分层或过渡的音乐片段可能会遇到严重的故障,这时考虑使用不同的加载类型并手动控制加载/卸载将是明智的。

我们还应该避免同时流式传输多个文件,因为这很可能会在磁盘上造成大量的缓存未命中,从而中断游戏玩法。这就是为什么这个选项主要用于背景音乐/环境声音效果,因为我们一次只需要一个。

通过混音组应用过滤器效果以减少重复

可以使用过滤器效果来修改通过音频源播放的声音效果,并且可以通过FilterEffect组件实现。每个单独的过滤器效果都会消耗一定量的内存和 CPU,这是一种在保持音频播放大量多样性的同时实现磁盘空间节省的好方法,因为一个文件可以通过不同的过滤器集进行调整,从而生成完全不同的声音效果。

由于额外的开销,在场景中过度使用过滤器效果可能会导致性能严重下降。更好的方法是利用 Unity 的音频混音实用工具(窗口 | 音频 | 音频混音)来生成多个音频源可以引用的常用过滤器效果模板,以最小化内存开销。

learn.unity.com/tutorial/audio-mixing的官方教程中,对音频混音的主题进行了详尽的介绍。

负责任地使用远程内容流式传输

通过 Unity,可以动态加载游戏内容,这可以是一种有效的方法来减少应用程序的磁盘占用,因为需要捆绑到可执行文件中的数据文件更少。这也提供了一种使用网络服务来确定在运行时向用户展示什么内容的方法。在 Unity 2017 及以后的版本中,可以通过UnityWebRequest类实现资产流式传输。

UnityWebRequest类使用了新的 HLAPI 和 LLAPI 网络层。这个类提供了各种工具来下载和访问主要是文本文件的内容。基于多媒体的请求应通过UnityWebRequestMultimedia辅助类进行。因此,如果请求AudioClip,我们应该调用UnityWebRequestMultimedia.GetAudioClip()来创建请求,并在下载完成后使用DownloadHandlerAudioClip.GetContent()来检索它。

这个 API 的新版本旨在在存储和提供我们请求的数据方面更加高效,因此通过DownloadHandlerAudioClip.GetContent()多次重新获取AudioClip不会导致额外的分配。相反,它只会返回对最初下载的AudioClip的引用。

考虑使用音频模块文件作为背景音乐

音频模块文件,也称为跟踪模块,是一种在不损失明显质量的情况下节省大量空间的绝佳方式。Unity 支持以下文件扩展名:.it.s3m.xm.mod。与常见的音频格式不同,这些格式像位流一样被读取,必须在运行时解码以生成特定的声音,而跟踪模块包含大量的小型、高质量样本,并像乐谱一样组织整个曲目,定义每个样本何时、何地、如何响亮、以何种音高以及使用何种特殊效果播放。这可以在保持高质量采样的同时提供显著的尺寸节省,因此,如果我们有机会使用音乐文件的跟踪模块版本,那么探索它是值得的。

纹理文件

在游戏开发中,纹理精灵这两个术语经常被混淆,所以值得明确区分:纹理只是一个图像文件,一个包含颜色数据的大列表,告诉解释程序图像的每个像素应该是什么颜色,而精灵可以看作是网格的 2D 等价物——它定义了图像将在游戏场景中如何以及在哪里出现。通常,精灵只是一个单一的四边形(一对三角形组合成一个矩形网格),它以平面的方式渲染到当前相机上。

同样还有一些被称为精灵图集的东西,它们是包含在更大的纹理文件中的大量单个图像集合,通常用于包含 2D 角色的动画。这些文件可以通过工具如 Unity 的精灵图集工具分割开来,以形成角色动画帧的单独纹理。

当然,你可以在 3D 环境中渲染 2D 精灵;然而,本质上,精灵仍然是一个 2D 元素,就像扑克牌即使用来搭建纸牌屋也仍然是平面的卡片一样。

网格和精灵都使用纹理在表面渲染图像。纹理图像文件通常在 Adobe Photoshop 或 GIMP 等工具中生成,然后以与音频文件相同的方式导入到我们的项目中。在运行时,这些文件被加载到内存中,推送到 GPU 的 VRAM 中,并在给定的绘制调用期间由着色器渲染到目标精灵或网格上。

纹理压缩格式

与音频文件类似,Unity 将以默认设置列表导入纹理文件,这些设置通常使事情保持简单,并在一般情况下表现良好,但有许多导入设置可供选择,允许我们通过一些自定义调整来提高纹理的质量和性能。当然,如果我们盲目地做出更改而不完全理解内部过程,那么做出更改同样可能导致质量和性能的降低。

第一个选项是文件的纹理类型。此设置将确定其他可用的选项,尤其是在高级下拉菜单下。并非所有导入选项都适用于所有类型,因此最好根据纹理的预期用途配置此选项,无论是设置为正常贴图、精灵、光照贴图等,因为这将揭示适合该类型的选项:

图片

与音频文件类似,我们可以导入多种常见的纹理文件格式(如.jpg.png),但应用程序中实际嵌入的压缩格式可能是许多不同的纹理压缩格式之一,这些格式非常适合给定平台的 GPU。这些格式代表了组织纹理颜色信息的不同方式,包括以下内容:

  • 用于表示每个通道的不同位数(使用的位数越多,可以表示的颜色就越多)

  • 每个通道不同的位数(例如,红色通道可能比绿色通道使用更多的位数)

  • 所有通道使用的总位数不同(位数越多,自然意味着更大的纹理和更多的磁盘和内存消耗)

  • 是否包含 alpha 通道

  • 也许最重要的是,数据打包的不同方式,这可以允许 GPU(或如果选择了错误的打包类型,则可能非常低效)进行高效的内存访问!

修改压缩的简单方法是使用压缩纹理导入选项来选择以下选项之一:

  • 低质量

  • 正常质量

  • 高质量

选择“无”表示不会应用压缩。在这种情况下,最终的纹理仍然会从导入的文件类型转换为格式,但它将选择一种不尝试压缩的格式,因此我们应该看到在牺牲大纹理文件的情况下,质量损失很小或没有。其他三个设置将选择一个压缩格式,这同样会根据平台而变化,Unity 将尝试选择与选项匹配的压缩格式。例如,选择“低质量”意味着 Unity 将选择一个大大减少纹理大小的压缩格式,但会产生一些压缩伪影,而选择“高质量”将消耗更多内存,具有更大的纹理大小和最少的伪影。再次强调,这是 Unity 自动做出的选择。

Unity 为每个平台以及这些压缩设置选择的精确格式可以在docs.unity3d.com/Manual/class-TextureImporterOverride.html找到。

Unity 选择的精确压缩格式可以被覆盖,尽管由于实际上每个平台都有自己的最佳自定义格式,因此可用的选项因平台而异。如果我们点击默认选项卡(位于最大尺寸选项上方)旁边的特定平台选项卡之一,我们将暴露特定平台的设置,并可以选择 Unity 要使用的确切压缩格式。

此外,还有 Crunch Compression 设置,它将在 DXT 压缩格式之上应用额外的有损压缩级别。此选项仅在其他压缩设置导致 DXT 压缩级别时才会显示。此设置可以在牺牲可能显眼的压缩伪影的情况下节省更多空间,具体取决于压缩质量设置。

纹理的几个导入设置相当普通,例如确定文件是否包含 alpha 通道,如何在其边缘包裹纹理,过滤方法,以及文件的最大可能分辨率(一个全局限制,以防止我们意外地将纹理放大超过其原始大小,在某些平台上)。然而,在这些导入设置中还有一些其他有趣的选择,我们将在适当的部分中介绍。

纹理性能提升

让我们探讨一下我们可以对我们的纹理文件进行的更改,这些更改可能会根据情况以及我们导入的文件内容来提高性能。在每种情况下,我们将探讨需要进行的更改以及它们产生的总体影响,无论是正面还是负面对内存或 CPU 的影响,纹理质量的增加或减少,以及我们可以在什么条件下使用这些技术。

减少纹理文件大小

给定纹理文件越大,将消耗更多的 GPU 内存带宽,在需要时推动纹理。如果每秒推送到内存中的总量超过显卡的总内存带宽,那么我们将遇到瓶颈,因为 GPU 必须等待所有纹理上传完毕,才能开始下一个渲染过程。较小的纹理比较大的纹理更容易通过管道,因此我们需要在高质量和性能之间找到一个良好的平衡点。

一个简单的测试来确定我们是否在内存带宽上受到瓶颈的方法是降低我们游戏最大和最丰富的纹理文件分辨率并重新启动场景。如果帧率突然提高,那么应用程序很可能是受纹理吞吐量限制。如果帧率没有提高或提高很少,那么我们可能还有一些内存带宽可以利用,或者渲染管道中的其他地方存在瓶颈,阻止我们看到任何进一步的改进。

智能使用 mipmap

如果玩家永远无法看到这些细节,那么就没有必要用高细节纹理渲染小而远的物体,如岩石和树木。当然,他们可能会看到一些轻微的改进,但性能成本可能不值得细节的微小增加。mipmap 的发明是为了解决这个问题(以及帮助消除大约在同一时间困扰视频游戏的走样问题),通过预先生成相同纹理的较低分辨率的替代品,并将它们保存在相同的内存空间中。在运行时,GPU 根据表面在透视视图中的大小选择适当的 mipmap 级别(基本上是基于当对象被渲染时的 texel 到像素的比率)。

通过启用生成 Mipmap 设置,Unity 自动处理这些纹理的较低分辨率副本的生成。这些替代品是在编辑器中使用高质量的重采样和过滤方法生成的,而不是在运行时。还有其他几种可用于 mipmap 生成的选项,这些选项会影响生成的级别质量,因此可能需要一些调整才能获得高质量的 mipmap 集。我们需要决定花费在这些值上的时间是否值得,因为 mipmap 的整个目的就是有意降低质量,首先是为了节省性能。

以下图像显示了如何将 1024 x 1024 的图像 mipmap 到多个较低分辨率的图像的重复:

图片

这些图像将被打包在一起以节省空间,本质上创建了一个最终纹理文件,该文件将比原始图像大 33%。这将消耗一些磁盘空间和 GPU 内存带宽来上传。

自从 Unity 2018.2 以来,还有另一种加载 mipmap 的方法:流式传输。正如音频案例中一样,mipmap 流式传输用于减少内存需求,以保持内存中多个 mipmap 纹理,同时不牺牲质量。实际上,如果我们启用 mipmap 流式传输,那么 Unity 将尝试根据场景中摄像机的位置动态地从磁盘加载纹理的正确分辨率。这可以根据场景(以及玩家的位置)节省高达 30%的纹理内存。

然而,这也有代价。首先,米级贴图的流式传输比生成慢;因此,如果你有瞬间的相机切换或者快速移动,你可能会开始注意到纹理质量的变化,因为米级贴图正在加载。这可以通过使用米级贴图流式传输 API 来缓解,以便在目标位置预加载米级贴图。

其次,目前可能并非所有平台都支持米级贴图流式传输。如果你想确保你的平台支持米级贴图流式传输,你可以检查SystemInfo.supportsMipStreaming属性。

如果你想了解更多关于纹理流的信息,你可以查看手册中的详细页面docs.unity3d.com/Manual/TextureStreaming-API.html

你可以通过将场景窗口的绘制模式设置为米级贴图,在某些点上看到应用程序正在使用哪些米级贴图级别。如果纹理比根据玩家当前视图应该的大小更大(额外的细节被浪费了),则会用红色突出显示纹理;而用蓝色突出显示则意味着它们太小(玩家正在观察一个低质量的纹理,具有较差的像素比)。

记住,只有当我们有需要在相机不同距离处渲染的纹理时,米级贴图才有用。如果我们有始终在主相机以相同距离渲染的纹理,这样米级贴图的替代品永远不会被使用,那么启用米级贴图只是浪费空间。同样,如果我们恰好有一个始终解析到相同的米级贴图级别,因为玩家的相机永远不会太近或太远以切换级别,那么简单地降低原始纹理的分辨率会更明智。

这种情况的良好例子包括任何用于 2D 游戏的纹理文件,UI 系统使用的纹理,或者用于 Skybox 或远背景的纹理,因为这些纹理在设计上总是与相机保持相同的距离,所以米级贴图基本上是没有意义的。其他好的例子包括仅出现在玩家附近的物体,如以玩家为中心的粒子效果、角色、仅出现在玩家附近的物体,以及只有玩家可以持有/携带的物体。

外部管理分辨率降低

Unity 致力于使事物尽可能易于使用,并为我们提供了将来自外部工具的项目文件放置到我们的项目工作空间的能力,例如.PSD.TIFF文件,这些文件通常很大,并且被分割成多个层级的图像。Unity 会自动从文件内容生成一个纹理文件,以便其余的引擎可以使用,这可以非常方便,因为我们只需要通过源控制维护文件的单一副本,当艺术家进行更改时,Unity 的副本会自动更新。

问题在于,Unity 从这些文件自动生成和压缩纹理引入的锯齿可能不如我们使用的纹理编辑工具为我们生成的效果。Unity 功能丰富,首先和最重要的是作为一个游戏开发平台,这意味着它在与其他软件开发人员全职工作的领域可能难以竞争。Unity 可能通过缩小图像为我们引入了锯齿伪影,因此我们可能发现自己通过导入比必要的更高分辨率的图像文件来绕过它,只是为了保持预期的质量水平;然而,如果我们首先通过外部应用程序缩小图像,我们可能遭受的锯齿伪影会少得多。在这些情况下,我们可能以较低的分辨率达到可接受的质量水平,同时消耗更少的总磁盘和内存空间。

我们可以养成在 Unity 项目中避免使用.PSD.TIFF文件的习惯(将它们存储在其他地方并将缩放后的版本导入 Unity),或者只是偶尔进行一些测试以确保我们没有使用比必要的更高分辨率的文件浪费文件大小、内存和 GPU 内存带宽。这可能会在项目文件管理上给我们带来一些不便,但如果我们愿意花时间比较不同的缩放版本,这可能会为某些纹理节省一些显著的存储空间。

调整各向异性过滤级别

各向异性过滤是一种在纹理以非常斜角(浅角度)查看时提高纹理图像质量的功能。以下截图显示了应用和不应用各向异性过滤的绘制道路线条的经典示例:

图片

在任何情况下,靠近摄像机的绘制线条看起来相当清晰,但随着它们远离摄像机,情况会发生变化。没有各向异性过滤,远处的绘制线条会越来越模糊和扭曲,而应用了各向异性过滤的线条则保持清晰和清晰。

可以通过 Aniso Level 设置在每个纹理的基础上手动修改应用于纹理的各向异性过滤强度,以及通过 Edit | Project | Quality 设置中的 Anisotropic Textures 选项全局启用/禁用。

与 mipmap 类似,这种效果可能会很昂贵,有时甚至是不必要的。如果我们确定场景中的某些纹理永远不会以斜角(例如远处的背景对象、UI 元素和横幅粒子效果纹理)被查看,那么我们可以安全地禁用这些纹理的各向异性过滤以节省运行时开销。我们还可以考虑根据每个纹理调整各向异性过滤效果的强度,以找到质量和性能之间的最佳平衡点。

考虑使用纹理集

Atlasing 是将许多较小的、孤立的纹理组合成一个单独的大纹理文件的技术,以最小化所需的材质数量和绘制调用次数。这实际上是一种利用动态批处理的方法。从概念上讲,这种技术与你在第三章“批处理的好处”中学到的最小化材质使用的方法非常相似。

每种独特的材质都需要额外的绘制调用,但每种材质仅支持一个主纹理。当然,它们也可以支持多个次级纹理,例如法线贴图和发射贴图。然而,通过将多个主纹理组合成一个单独的大纹理文件,我们可以最小化渲染共享此纹理的对象所需的绘制调用次数:

图片

需要额外的工作来修改网格或精灵对象使用的 UV 坐标,以便仅采样它所需的大纹理文件的部分,但好处是显而易见的:减少绘制调用会导致 CPU 工作负载的减少,如果我们的应用程序在 CPU 上成为瓶颈,则帧率会提高。假设合并的纹理文件分辨率等同于所有组合图像的分辨率,将不会损失质量,内存消耗也将基本相同。请注意,图集化不会减少内存带宽消耗,因为推送到 GPU 的数据量也将是相同的。它只是恰好被捆绑在一个更大的纹理文件中。

只有当所有给定的纹理都需要相同的着色器时,Atlasing 才是一个选项。如果某些纹理需要通过着色器应用独特的图形效果,那么它们必须被隔离到它们自己的材质中,并在单独的组中进行图集化。

在 Unity 中开发移动游戏时,Atlasing 成为了一种常见的策略,尤其是在包含大量 2D 图形的游戏中。当在 Unity 中开发移动游戏时,由于绘制调用通常是这些平台上的最常见瓶颈,Atlasing 变得实际上至关重要。然而,我们并不希望手动生成这些图集文件。如果我们能够继续单独编辑我们的纹理并自动化将它们组合成更大文件的任务,生活将会简单得多。

Unity Asset Store 中的许多与 GUI 相关的工具都提供了自动纹理图集功能。互联网上散布着一些可以处理这项工作的独立程序,Unity 还可以以资产的形式生成精灵图集。这些可以通过访问“资产”|“创建”|“精灵图集”来创建。

查看 Unity 文档以了解更多关于这个有用功能的信息,请访问docs.unity3d.com/Manual/class-SpriteAtlas.html

注意,精灵图集功能实际上取代了 Unity 旧版本中的精灵打包工具。

地图纹理化也不必应用于 2D 图形和 UI 元素。如果我们碰巧在创建大量低分辨率纹理,我们可以将这项技术应用于 3D 网格。具有简单纹理分辨率或平面着色、低多边形艺术风格的 3D 游戏是这种方式进行地图纹理化的理想候选者。

然而,由于动态分批处理仅影响非动画网格(即MeshRenderer,但不包括SkinnedMeshRenderer),将动画角色的纹理文件组合到图集中是没有必要的。由于它们是动画的,GPU 需要将每个对象的骨骼乘以当前动画状态的变化。这意味着每个角色都需要进行独特的计算,并且无论我们尝试让它们共享材质,它们都会导致额外的绘制调用。

因此,将纹理组合用于动画角色应该仅作为方便和节省空间的措施;例如,在平面着色、低多边形艺术风格的游戏中,如果一切使用的是公共调色板,我们可以通过使用单个纹理来为整个游戏世界、物体和角色节省空间。

地图纹理化的缺点主要在于开发时间和工作流程成本。为了利用地图纹理化,需要对现有项目进行大量努力进行彻底的改造,这仅仅是为了确定是否值得付出这些努力,就可能是一项繁重的工作。此外,我们还需要注意生成对于目标平台来说过大的纹理文件。

一些设备(特别是移动设备)对可以拉入 GPU 最低内存缓存的纹理大小有相对较低的限制。如果图集纹理文件过大,则必须将其拆分为较小的纹理,以便适应目标内存空间。如果设备的 GPU 在每次绘制调用时都需要来自图集不同部分的纹理,那么我们不仅会引发大量的缓存未命中,还可能发现我们阻塞了内存带宽,因为纹理不断地从 VRAM 和低级缓存中拉取。

如果将图集保留为单独的纹理,我们可能就不会遇到这个问题。相同的纹理交换将会发生,但代价是额外的绘制调用,将导致交换的文件更小。在这个阶段,我们最好的选择可能是降低图集的分辨率或生成多个较小的图集,以便更好地控制它们如何动态分批处理。

纹理合成显然不是完美的解决方案,如果我们不确定它是否会带来性能上的好处,那么我们应该小心不要在其实施上浪费太多时间。非常一般地说,具有非常简单的 2D 艺术风格的移动游戏可能不需要使用纹理合成;然而,试图与高质量资产竞争或使用任何类型的 3D 图形的移动游戏可能应该从开发初期就开始整合纹理合成,因为项目很可能很快就会达到纹理吞吐量限制。他们甚至可能需要针对每个平台和每个设备进行许多优化,以便达到广泛的受众。

同时,我们应该考虑只有在我们的绘制调用次数超过合理的硬件预期时才将纹理合成应用于高质量桌面游戏,因为我们希望许多纹理保持高分辨率以获得最佳质量。低质量桌面游戏可能可以承担避免纹理合成的费用,因为绘制调用不太可能是最大的瓶颈。

当然,无论产品是什么,如果我们因为过多的绘制调用而受到 CPU 的限制,并且已经用尽了多种替代技术,那么在大多数情况下,纹理合成是一种非常有效的性能提升方法。

调整非方形纹理的压缩率

纹理文件通常以平方、2 的幂格式存储,这意味着它们的高度和宽度长度相等,其大小是 2 的幂——例如,一些典型的尺寸是 256 x 256 像素、512 x 512 和 1024 x 1024,等等。

提供矩形 2 的幂纹理(如 256 x 512)或非 2 的幂格式(如 192 x 192)是可能的,但创建这样的纹理是不推荐的。一些 GPU 需要平方纹理格式,因此 Unity 将通过自动扩展纹理以包括额外的空空间来适应 GPU 期望的格式,这将导致额外的内存带宽成本,将实际上未使用且无用的数据推送到 GPU。其他 GPU 可能支持非 2 的幂纹理,但这可能比平方纹理的采样速度慢。

因此,第一个建议是完全避免非方形和/或非 2 的幂纹理。如果图像可以放置在平方、2 的幂纹理中,并且不会因为挤压/拉伸而导致太多质量下降,那么我们应该只应用这些更改,以保持 CPU 和 GPU 的满意。作为第二个选项,我们可以通过 Unity 中的纹理文件非 2 的幂导入设置来定制这种缩放行为,尽管这是一个自动化的过程,它可能不会给我们带来预期的图形质量。

稀疏纹理

稀疏纹理,也称为大纹理瓦片纹理,提供了一种在运行时从磁盘有效流式传输纹理数据的方法。相对而言,如果 CPU 的操作速度以秒为单位,那么磁盘的操作速度将以天为单位。因此,常见的建议是在游戏过程中应尽可能避免硬盘访问,因为任何此类技术都可能造成比可用更多的硬盘访问,导致我们的应用程序陷入停滞。

然而,如果我们聪明地提前开始传输纹理部分的数据,稀疏纹理提供了一些有趣的性能节省技术。稀疏纹理是通过将许多纹理组合成一个巨大的纹理文件来准备的,这个文件如果作为一个单独的纹理文件加载到图形内存中将会太大。这与图集的概念类似,但包含纹理的文件非常大——例如,32,768 x 32,768 像素——并且会包含相当多的颜色细节,如每像素 32 位(这将导致一个消耗 4 GB 磁盘空间的纹理文件)。想法是通过手动选择纹理的小部分来动态地从磁盘加载,在游戏需要它们之前的一瞬间从磁盘拉取它们,从而节省大量的运行时内存和内存带宽。这种技术的成本主要是文件大小要求以及可能持续的磁盘访问。这种技术的其他成本可以克服,但通常需要大量的场景准备工作。

游戏世界需要以这种方式创建,以最大限度地减少纹理交换的数量。为了避免非常明显的纹理闪烁问题,纹理子部分必须从磁盘拉入 RAM,留出足够的时间,这样 GPU 不需要等待就可以开始传输到 VRAM(与它通常不需要等待预先加载到 RAM 中的普通纹理文件的方式非常相似)。这通过在纹理文件的设计中保持给定场景的常见元素在纹理的相同区域,以及通过在游戏过程中的关键时刻触发新的纹理子部分加载,并确保新瓦片的磁盘访问可以快速定位,而不会出现极端的缓存缺失来实现。如果处理得当,稀疏纹理可以在场景质量和内存节省方面带来令人印象深刻的效益。

这是在游戏行业中的一个高度专业化的技术,尚未得到广泛应用,部分原因是因为它需要专门的硬件和平台支持,部分原因是因为很难做得很好。Unity 关于稀疏纹理的文档随着时间的推移有所改进,并提供了一个示例场景,展示了其效果,可以在docs.unity3d.com/Manual/SparseTextures.html找到。

对于认为自己足够高级可以尝试稀疏纹理的 Unity 开发者来说,花时间进行一些研究以检查稀疏纹理是否适合他们的项目可能是值得的,因为它承诺可以节省一些显著的性能。

程序材质

程序材质,也称为材质,是一种在运行时通过结合小而高质量的纹理样本和自定义数学公式来程序生成纹理的方法。程序材质的目标是在初始化期间通过数学运算而不是静态颜色数据生成纹理,以牺牲额外的运行时内存和 CPU 处理能力,从而大大减少应用程序的磁盘占用。

纹理文件有时是游戏项目最大的磁盘空间消费者,而且下载时间对完成下载速度和让人们尝试我们的游戏(即使它是免费的)有巨大的负面影响是众所周知的事实。程序材质允许我们牺牲一些初始化和运行时处理能力以换取更快的下载速度。这对于试图通过图形保真度竞争的移动游戏来说非常重要。

对于 Unity 2019 来说,程序材质不再是 Unity 的一部分。相反,它们作为单独的插件提供。您可以在官方页面了解更多关于材质的信息:www.substance3d.com/integrations/substance-in-unity

异步纹理上传

我们尚未介绍的最后一种纹理导入选项是读写启用选项。默认情况下,此选项是禁用的,这是好事,因为它允许纹理利用异步纹理上传功能,该功能有两个好处:纹理将从磁盘异步上传到 RAM,并且当 GPU 需要纹理数据时,传输发生在渲染线程上,而不是主线程上。只要缓冲区包含新数据,纹理就会被推送到循环缓冲区,持续不断地将数据推送到 GPU。如果没有新数据,则它将提前退出进程并等待新的纹理数据请求。

最终,这减少了为每一帧准备渲染状态所花费的时间,并允许将更多的 CPU 资源用于游戏逻辑、物理引擎等。当然,仍然会在主线程上花费一些时间来准备渲染状态,但将纹理上传任务移动到单独的线程可以为主线程节省大量的 CPU 时间。

然而,启用对纹理的读写访问实际上是在告诉 Unity 我们可能在任何时间读取和编辑此纹理。这意味着 GPU 每次都需要新鲜访问它,因此它将禁用该纹理的异步纹理上传;所有上传都必须在主线程上执行。我们可能希望为诸如在画布上模拟绘画颜色或将来自互联网的图像数据写入预制的纹理等操作启用此选项,但缺点是 GPU 必须始终等待对纹理的任何更改被应用后才能上传,因为它无法预测这些更改何时发生。

此外,异步纹理上传仅适用于我们明确导入到项目中且在构建时存在的纹理,因为该功能仅在纹理被打包到特殊的可流式传输资源中时才有效。因此,通过LoadImage(byte[])生成的任何纹理、从外部位置导入/下载的纹理资产,或通过Resources.Load()资源文件夹中加载的纹理(它们都隐式调用LoadImage(byte[]))将不会被转换为可流式传输内容,因此将无法使用异步纹理上传。

可以调整最大允许时间的上限,以便将其用于异步纹理上传,以及 Unity 应使用的总循环缓冲区大小,以推送我们想要上传的纹理。这些设置可以在“编辑”|“项目设置”|“质量”|“其他”中进行调整,分别命名为异步上传时间片和异步上传缓冲区大小。我们应该将异步上传时间片值设置为 Unity 在渲染线程上花费在异步纹理上传上的最大毫秒数。将异步上传缓冲区大小值设置为可能需要的最大纹理文件大小,如果同一帧需要多个新纹理,则额外添加一些缓冲区。纹理数据被复制的循环缓冲区将根据需要扩展,但这通常成本较高。由于我们可能已经提前知道需要循环缓冲区的大小,我们可以将其设置为最大预期大小,以避免在需要调整缓冲区大小时出现潜在的帧率下降。我们现在继续讨论下一个主题——网格和动画文件类型。

网格和动画文件

网格和动画文件类型基本上是顶点和骨骼蒙皮数据的大型数组,我们可以应用各种技术来最小化文件大小,同时保持相似,如果不是完全相同的外观。还有方法可以通过批处理技术降低渲染大量此类对象的成本。让我们看看我们可以应用于此类文件的一系列性能提升技术。

减少多边形数量

减少多边形数量是获得性能的最明显方法,应始终考虑。事实上,由于我们不能使用皮肤网格渲染器批量处理对象,这是减少动画对象 CPU 和 GPU 运行时开销的好方法之一。

减少多边形数量简单直接,并且为艺术家清理网格所需的时间提供了 CPU 和内存成本节约。在这个时代,物体的许多细节几乎完全基于详细的纹理和复杂的着色,因此我们通常可以在现代网格上移除大量顶点,而大多数用户都无法察觉到差异。

调整网格压缩

Unity 为导入的网格文件提供了四种不同的网格压缩设置:关闭、低、中、高。提高此设置会将浮点数据转换为固定值,降低顶点位置/法线方向精度,简化顶点颜色信息等。这将对包含许多相邻小部件的网格产生明显影响,例如栅栏或格栅。如果我们是生成网格的,可以通过调用MeshRenderer组件的Optimize()方法(当然,这需要一些时间来完成)来实现相同类型的压缩。

在“编辑 | 项目设置 | 玩家 | 其他设置”中还可以找到两个全局设置,它们会影响网格数据的导入方式。具体如下:

  • 顶点压缩:我们可以使用此选项配置在启用网格压缩时导入网格文件将优化的数据类型,如果我们想要精确的法线数据(用于照明),但不太关心位置数据,则可以在此配置。不幸的是,这是一个全局设置,将影响所有导入的网格(尽管由于它是玩家设置,因此可以按平台进行配置)。

  • 优化网格数据:启用“优化网格数据”将移除网格中不需要的任何数据。所以,如果网格包含切线信息,但着色器从不要求它,那么 Unity 在构建时将忽略它。

在每种情况下,这些好处都是以增加加载网格所需额外时间为代价来减少应用程序的磁盘占用,因为必须在需要之前花费额外时间解压缩数据。

3D 网格构建/动画工具通常提供自己的内置自动化网格优化方式,形式为估计整体形状并将网格简化为更少的多边形。这可能导致质量显著下降,如果使用,应进行彻底测试。

正确使用读写权限

读写启用标志允许在运行时通过脚本或由 Unity 自动进行更改,类似于它用于纹理文件的方式。内部来说,这意味着它将保留原始网格数据在内存中,直到我们想要复制它并动态地对其进行更改。禁用此选项将允许 Unity 在确定要使用的最终网格后从内存中丢弃原始网格数据,因为它知道它将不会更改。

如果我们在整个游戏中只使用网格的均匀缩放版本,那么禁用此选项将节省运行时内存,因为我们不再需要原始网格数据来制作网格的进一步缩放副本(顺便说一句,这就是 Unity 在动态批处理时按比例因子组织对象的方式)。因此,Unity 可以提前丢弃这些不需要的数据,因为我们不会在下次应用程序启动之前再次需要它。

然而,如果网格在运行时经常以不同的比例重新出现,那么 Unity 需要将此数据保留在内存中,以便它可以更快地重新计算新的网格;因此,启用读写启用标志将是明智的。禁用它将要求 Unity 每次网格重新引入时不仅要重新加载网格数据,还要同时创建缩放后的副本,这可能导致性能中断。

Unity 试图在初始化时检测此设置的正确行为,但当网格在运行时以动态方式实例化和缩放时,我们必须通过启用此设置来强制处理。这将提高对象的实例化速度,但会消耗一些内存开销,因为原始网格数据会保留直到需要时。

注意,这种潜在的开销成本也适用于使用生成碰撞器选项时。

考虑烘焙动画

使用烘焙动画需要通过我们使用的 3D 绑定和动画工具对资产进行更改,因为 Unity 本身不提供此类工具。动画通常以关键帧信息的形式存储,它使用这些信息来跟踪特定的网格位置,并在运行时使用皮肤数据(骨骼形状、分配、动画曲线等)在它们之间进行插值。烘焙动画意味着有效地在每个帧中采样并将每个顶点的每个位置硬编码到网格/动画文件中,无需插值和皮肤数据。

使用烘焙动画有时可以比混合/蒙皮动画为某些对象带来更小的文件大小和内存开销,因为蒙皮数据可能需要占用出人意料大的空间来存储。这最有可能发生在相对简单的对象或具有简短动画的对象上,因为我们实际上会用硬编码的顶点位置序列替换程序数据。因此,如果网格的多边形数量足够低,以至于存储大量顶点信息比蒙皮数据更便宜,那么我们可能会通过这种简单的更改看到一些显著的节省。

此外,烘焙样本的频率通常可以通过导出应用程序进行自定义。应该测试不同的采样率,以找到动画的关键时刻仍然能够通过简化估计凸显出来的良好值。

合并网格

强制将网格合并成一个大型的单个网格可以是一个方便的选项,以减少绘制调用,尤其是如果网格太大而无法进行动态批处理,并且与其他静态批处理组配合不佳时。这本质上等同于静态批处理,但它是由人工执行的,因此如果静态批处理可以为我们处理这个过程,有时这会是一种浪费的努力。

请注意,如果网格的任何单个顶点在场景中可见,则整个对象将作为一个整体一起渲染。如果网格大部分时间只部分可见,这可能会导致大量的处理浪费。这种技术还带来一个缺点,即它会生成一个新的整个网格资产文件,我们必须将其存入我们的场景中,这意味着我们对原始网格所做的任何更改都不会反映在合并后的网格中。这导致每次需要更改时都需要大量的繁琐工作流程,因此如果静态批处理是一个选项,应该使用它而不是这种方法。

在线有一些工具可以将网格文件合并在一起,用于 Unity。它们只需通过资产商店或 Google 搜索即可获得。

资产包和资源

在第二章“脚本策略”中,我们提到了资源和序列化的话题,应该相当清楚,资源系统在原型设计和项目早期阶段都可以带来很大的好处,并且可以在范围有限的游戏中相对有效地使用。

然而,专业的 Unity 项目应该更倾向于使用资产包系统。这有几个原因。首先,在构建方面,资源系统并不非常可扩展。所有资源都合并成一个单一的巨大序列化文件二进制数据块,其中包含一个索引列表,指示各种资产在该文件中的位置。这可能会很难管理,并且随着我们向列表中添加更多数据,构建时间可能会很长。

其次,资源系统从序列化文件获取数据的能力以Nlog(N)的方式扩展,这应该让我们对增加N的值非常谨慎。第三,资源系统使得我们的应用程序按设备提供不同的资产数据变得难以操作,而资产包通常使这个问题变得微不足道。最后,资产包可以用来为应用程序提供小型的、定期的自定义内容更新,而资源系统则需要更新以完全替换整个应用程序才能达到相同的效果。

资产包与资源共享了许多共同的功能,例如从文件中加载、异步加载数据以及卸载不再需要的我们不再需要的数据。然而,它们还提供了更多的功能,如内容流、内容更新、内容生成和共享。所有这些都可以极大地提高我们应用程序的性能。我们可以提供具有更小磁盘足迹的应用程序,让用户在游戏前或游戏中下载额外的内容,在运行时流式传输资产以最小化应用程序的初始加载时间,并且可以在每个平台上为应用程序提供更优化的资产,而无需推送完整的应用程序以覆盖用户。

当然,资产包也有其缺点。它们比资源更复杂,设置和维护起来更复杂,理解起来也更复杂,因为它们使用了一个比资源系统更复杂的系统来访问资产数据。充分利用其功能(如流式传输和内容更新)需要大量的额外 QA 测试,以确保服务器正确地提供内容,并且游戏能够读取和更新其内容以匹配。因此,只有在我们的团队能够支持它们所需的额外工作量时,才最好使用资产包。

本书中不涉及资产包系统的教程,但网上和 Unity 文档中有很多有用的指南。

查看 Unity 教程learn.unity.com/tutorial/assets-resources-and-assetbundles,了解更多关于资产包系统信息。

如果您需要进一步的说服,那么 2017 年 4 月的一篇 Unity 博客文章应该有助于揭示资产包系统如何在运行时更有效地使用内存,这是资源系统无法通过内存池提供的。您可以在blogs.unity3d.com/2017/04/12/asset-bundles-vs-resources-a-memory-showdown/找到这篇博客。

摘要

我们可以通过调整导入的资产来探索许多不同的机会,从而提高我们应用程序的性能。从另一个角度来看,也有许多方法可以通过资产管理不善来破坏我们应用程序的性能。几乎每一个导入配置的机会都是在一项性能指标或工作流程任务与另一项之间的权衡。通常这意味着通过压缩来节省磁盘占用空间,但运行时需要 CPU 来解压缩数据,或者更快地访问数据,同时降低最终展示的质量水平。因此,我们必须保持警惕,只为合适的资产选择合适的技巧,出于合适的原因。

这就结束了我们通过艺术资产操作来提高性能的探索。在下一章中,我们将探讨如何提高我们对 Unity 物理引擎的使用。

第五章:更快的物理

到目前为止,我们所探讨的每一个性能提升建议都主要集中在降低资源成本和避免帧率问题。然而,在最根本的层面上,追求峰值性能意味着提升用户体验。这是因为每一个帧率波动、每一次崩溃以及对于特定市场来说过于昂贵的系统要求,最终都会降低产品的质量。物理引擎是一个独特的子系统类别,其行为和一致性对产品质量有着显著的影响。花时间改善它们的行为通常是值得的。

如果重要的碰撞事件被遗漏,游戏在计算复杂的物理事件时会冻结,或者玩家会穿过地板,这些情况对游戏品质有明显的负面影响。一些小故障通常是可以忍受的,但持续的问题会妨碍游戏体验。这通常会导致玩家脱离游戏体验,而用户是否觉得不方便、讨厌或好笑则是一个未知数。除非我们的游戏明确针对喜剧物理类型(如QWOPGoat Simulator),否则我们应该努力避免这些情况。

有些游戏可能根本不使用物理引擎,而有些游戏在游戏过程中需要物理引擎处理大量的任务,例如数百个对象之间的碰撞检测、触发体积以启动过场动画、射线投射以进行玩家攻击和 UI 行为、收集特定区域内的对象列表,或者只是使用物理效果作为视觉糖果,让大量的物理粒子四处飞舞。其重要性也取决于所创建的游戏类型。例如,在平台游戏和动作游戏中,正确调整物理引擎至关重要——玩家角色对输入的反应以及世界对玩家角色的反应是使游戏感觉响应和有趣的最关键的两个方面,而在大型多人在线MMO)游戏中,物理交互通常有限,精确的物理可能不那么重要。

因此,在本章中,我们将介绍通过 Unity 的物理引擎来减少 CPU 峰值、开销和内存消耗的方法,同时也会包括改变物理行为以提升或至少保持游戏品质,同时优化性能的方法。在本章中,我们将涵盖以下内容:

  • 理解 Unity 的物理引擎如何工作:

    • 时间步长和固定更新

    • 碰撞体类型

    • 碰撞

    • 射线投射

    • Rigidbody 活跃状态

  • 物理性能优化:

    • 如何构建场景以实现最佳的物理行为

    • 使用最合适的类型进行碰撞体

    • 优化碰撞矩阵

    • 提高物理一致性并避免易出错的行为

    • 布娃娃和其他基于关节的对象

理解物理引擎

Unity 在技术上具有两个不同的物理引擎:Nvidia 的 PhysX 用于 3D 物理,开源项目 Box2D 用于 2D 物理。然而,它们的实现高度抽象,从我们通过主 Unity 引擎配置的高级 Unity API 的角度来看,这两种物理引擎解决方案以功能上相同的方式运行。

在任何情况下,我们对 Unity 的物理引擎了解得越多,我们就能更好地理解可能的性能提升。因此,首先,我们将介绍 Unity 实现这些系统的理论。

物理和时间

物理引擎通常假设时间以固定值前进,Unity 的两个物理引擎都以这种方式运行。每个迭代都称为时间步长。物理引擎将只使用精确的时间值解决每个时间步长,这与渲染前一帧所需的时间无关。在 Unity 中,这被称为固定更新时间步长,默认设置为 20 毫秒(每秒 50 次更新)。

如果物理引擎使用可变时间步长,由于架构(在如何表示浮点值方面)的差异以及客户端之间的延迟,生成两个不同计算机之间碰撞和力的恒定结果可能会很具挑战性。这种物理引擎往往会在多人客户端之间或记录的重放期间产生非常不一致的结果。

下面的图显示了 Unity 执行顺序图的一个重要片段:

完整的执行顺序图可以在docs.unity3d.com/Manual/ExecutionOrder.html找到。

正如我们在前面的图中可以看到的,固定更新是在物理引擎执行更新之前处理的,两者密不可分。这个过程从确定是否已经过去了足够的时间以启动下一个固定更新开始。一旦确定这一点,结果将取决于自上次固定更新以来过去的时间量。

如果已经过去了足够的时间,那么固定更新过程将调用场景中所有活动 MonoBehaviours 定义的所有FixedUpdate()回调,然后是任何与固定更新相关联的协程(特别是那些yieldWaitForFixedUpdate的协程)。请注意,在这两个过程中调用的方法执行顺序没有保证,所以我们绝对不应该基于这个假设编写代码。一旦这些任务完成,物理引擎就可以开始处理当前的时间步长并调用任何必要的触发器和碰撞器回调。

相反,如果自上次固定更新以来过去的时间太少(即少于 20 毫秒),则当前固定更新将被跳过,并且在前一迭代期间不会执行之前列出的所有任务。此时,输入、游戏逻辑和渲染将允许按正常方式发生。一旦这项活动完成,Unity 将检查是否需要下一次固定更新。

在高帧率下,渲染更新可能会在物理引擎有机会更新自己之前完成多次。因此,固定更新和物理引擎比渲染具有更高的优先级,同时也迫使物理模拟进入固定帧率。

为了确保物体在固定更新之间平滑移动,物理引擎(包括 Unity 的)将在物体在上一状态中的位置和根据剩余时间直到下一次固定更新后它应该所在的位置之间插值每个物体的可见位置。这种插值确保即使物体的物理位置、速度等更新频率低于渲染帧率,物体看起来仍然会平滑移动。

FixedUpdate() 回调是一个定义我们希望帧率无关的游戏行为的实用地方。由于假设固定更新频率更容易处理,AI 计算通常在固定更新中解决。

最大允许时间步长

需要注意的是,如果自上次固定更新以来已经过去了很多时间(例如,游戏瞬间冻结),那么固定更新将继续在同一个固定更新循环中进行计算,直到物理引擎赶上当前时间。例如,如果前一帧渲染耗时 100 毫秒(例如,突然的 CPU 峰值导致主线程长时间阻塞),那么物理引擎需要更新五次。因此,FixedUpdate() 方法将在再次调用 Update() 之前被调用五次,这是由于默认的固定更新时间步长为 20 毫秒。当然,如果在这五次固定更新期间有大量的物理活动需要处理,以至于处理它们需要超过 20 毫秒,那么物理引擎将需要调用第六次更新。

因此,在物理活动频繁的时刻,物理引擎处理固定更新所需的时间可能会超过它模拟的时间。例如,如果处理固定更新模拟了 20 毫秒的游戏时间需要了 30 毫秒,那么它就落后了,需要处理更多的时间步来尝试跟上,但这可能会导致它进一步落后,需要处理更多的时间步,依此类推。在这些情况下,物理引擎永远无法逃离固定更新循环并允许渲染另一帧。这个问题通常被称为 死亡螺旋。然而,为了防止物理引擎在这些时刻锁定我们的游戏,物理引擎被允许处理每个固定更新循环的最大时间量。这个阈值被称为 Maximum Allowed Timestep,如果当前批次的固定更新处理时间过长,那么它将简单地停止,并放弃进一步的处理,直到下一次渲染更新完成。这种设计允许渲染管线至少渲染当前状态,并允许在物理引擎出现异常(故意为之)的罕见时刻,让用户输入和游戏逻辑做出一些决策。

此设置可以通过 Edit | Project Settings | Time | Maximum Allowed Timestep 访问。

物理更新和运行时更改

当物理引擎处理一个给定的时间步长时,它必须移动任何活动的 Rigidbody 对象(具有 Rigidbody 组件的 GameObject),检测任何新的碰撞,并在相应的对象上调用碰撞回调。Unity 文档明确指出,对 Rigidbody 对象的更改应在 FixedUpdate() 和其他物理回调中处理,正是出于这个原因。这些方法与物理引擎的更新频率紧密耦合,而不是与其他游戏循环的部分,如 Update()

这意味着像 FixedUpdate()OnTriggerEnter() 这样的回调是进行 Rigidbody 变更的安全地方,而像 Update() 和在 WaitForSecondsWaitForEndOfFrame 上产生协程的方法则不是。忽视这条建议可能会导致意外的物理行为,因为物理引擎有机会捕捉和处理所有更改之前,可能对同一对象进行多次更改。

Update()回调中应用力或冲量,而不考虑这些调用的频率,尤其危险。例如,想象一下在玩家按住键的同时在Update函数中应用 10 牛顿的力:在两个不同的设备上,由于我们在固定更新中做了同样的事情,结果速度将完全不同。实际上,我们不能依赖于Update()调用的数量保持一致。然而,在FixedUpdate()回调中这样做将更加一致。因此,我们必须确保所有与物理相关的行为都在适当的回调中处理,否则我们可能会引入一些特别难以复现的游戏玩法错误。

逻辑上可以推断,我们在任何给定的固定更新迭代中花费的时间越多,我们用于下一轮游戏和渲染的时间就越少。大多数情况下,这会导致一些微不足道的、不易察觉的后台处理任务,因为物理引擎几乎没有工作要做,而FixedUpdate()回调有足够的时间来完成它们的工作。然而,在某些游戏中,物理引擎在每次固定更新期间可能会执行大量的计算。这种在物理处理时间上的瓶颈将影响我们的帧率,导致帧率随着物理引擎承担更大的工作负载而急剧下降。本质上,渲染管线将尝试按常规进行,但在需要固定更新时,物理引擎需要花费很长时间来处理,渲染管线将几乎没有时间在帧到期之前生成当前显示,导致突然的卡顿。这还加上物理引擎因为达到最大允许时间步长而提前停止的视觉效果。所有这些加在一起将产生一个较差的用户体验。

为了保持流畅和一致的帧率,我们需要通过最小化物理引擎处理任何给定时间步所需的时间来尽可能多地释放渲染时间。这适用于最佳情况(没有移动)和最坏情况(所有东西同时撞击到其他东西)。我们可以在物理引擎中调整几个与时间相关的特性和值,以避免这些性能陷阱。

静态碰撞体和动态碰撞体

Unity 中关于静态动态术语存在相当极端的命名空间冲突。当使用静态时,通常意味着正在讨论的对象或过程是不动的,保持不变,或者只存在于一个位置,而动态则相反——倾向于移动或改变的对象或过程。然而,重要的是要记住,这些都是独立的话题,静态和动态术语的用法在每个情况下都不同。我们已经介绍了 GameObject 的静态子标志、动态批处理和静态批处理系统,以及 C#语言中的静态类、静态变量和静态函数的概念。所以,为了更加混乱,Unity 还有静态和动态碰撞体的概念。

动态碰撞体指的是包含一个Collider组件(可能是几种类型之一)和一个Rigidbody组件的 GameObject。通过将Rigidbody附加到与碰撞体相同的对象上,物理引擎会将该碰撞体视为必须对外部力(如重力)和与其他刚体碰撞做出反应的物理对象的边界体积。如果我们让一个动态碰撞体与另一个碰撞体相撞,它们将根据牛顿运动定律(或者至少是计算机使用浮点运算所能达到的最佳程度)做出反应。

我们还可以有不含Rigidbody组件的碰撞体,这些被称为静态碰撞体。它们实际上充当不可见的障碍物,动态碰撞体可以与之碰撞,但静态碰撞体不会做出反应。换一种方式来想,想象一下没有Rigidbody组件的对象具有无限质量。无论你多么用力地把石头扔向具有无限质量的对象,它都不会移动,但你仍然可以期待石头像刚刚撞到实墙一样做出反应。这使得静态碰撞体非常适合作为世界障碍物和其他必须不动的障碍物。

物理引擎自动将动态和静态碰撞体分别放入两个不同的数据结构中,每个数据结构都针对现有碰撞体的类型进行了优化。这有助于简化未来的处理任务,因为例如,在两个静态碰撞体之间解决碰撞和冲量是没有意义的。

碰撞检测

Unity 中碰撞检测有三个设置,可以在RigidBody组件的碰撞检测属性中进行配置:离散、连续和连续动态。

离散设置启用离散碰撞检测,它基于物体的速度和经过的时间在每个时间步长将物体传送一小段距离。一旦所有物体都移动了,它就会执行边界体积检查,以查找任何重叠部分,将它们视为碰撞,并根据它们的物理属性和重叠方式解决它们。如果小物体移动得太快,这种方法可能会错过碰撞。

下图显示了离散碰撞检测是如何在物体从一个位置瞬移到下一个位置时捕捉两个物体的:

剩余的任一设置都将启用连续碰撞检测,它通过在当前时间步长内插值碰撞器从起始位置到结束位置,并检查沿途是否有任何碰撞。这减少了漏检碰撞的风险,并以比离散碰撞检测显著更高的 CPU 开销为代价,生成更精确的模拟。

连续设置仅允许在给定的碰撞器和静态碰撞器之间启用连续碰撞检测。相同碰撞器与动态碰撞器之间的碰撞仍然会使用离散碰撞检测。

同时,ContinuousDynamic 设置允许碰撞器与所有静态和动态碰撞器之间启用连续碰撞检测,因此在资源消耗方面是最昂贵的。

下图显示了离散碰撞检测和连续碰撞检测方法是如何作用于一对小型、快速移动的物体的:

这是一个为了说明目的的极端例子。在离散碰撞检测的情况下,我们可以观察到物体在单个时间步长内移动的距离是其大小的四倍左右,这通常只会发生在非常小且速度非常高的物体上,因此,如果我们的游戏运行得最优,这种情况是非常罕见的。在绝大多数情况下,物体在单个 20 毫秒时间步长内移动的距离相对于物体的大小来说要小得多,因此碰撞很容易被离散碰撞检测方法捕捉到。

碰撞器类型

Unity 中有四种不同的 3D 碰撞器类型。按照性能成本从低到高的顺序,如下所示:

  • 球体

  • 胶囊体

  • 矩形

  • 网格

前三种碰撞类型通常被称为原语,并保持精确的形状,尽管它们通常可以沿不同方向缩放以满足特定需求。然而,网格碰撞器可以根据分配的网格定制为特定形状。还有三种类型的 2D 碰撞器——圆形、矩形和多边形,它们的功能分别类似于球体、矩形和网格碰撞器。以下所有信息大多可以转移到等效的 2D 形状。

注意,我们也可以在 Unity 中生成圆柱形 3D 对象,但这仅限于其图形表示。自动生成的圆柱形状使用胶囊体碰撞器来表示其物理边界体积,这可能会产生不符合预期的物理行为。

此外,网格碰撞体有两种类型:凸面凹面。区别在于凹面形状至少有一个大于 180 度的内部角度(形状两个内部边之间的角度)。为了说明这一点,以下图表显示了凸面凹面形状之间的区别:

图片

记忆凸面和凹面形状之间区别的一个简单方法是,凹面形状在其内部至少有一个洞穴。

两种网格碰撞体类型使用相同的组件(一个MeshCollider组件)。通过凸面复选框切换生成的网格碰撞体类型。启用此选项将允许对象与所有原始形状(球体、盒子等)以及其他启用了凸面的网格碰撞体发生碰撞。

此外,如果对于一个具有凹面形状的网格碰撞体启用了凸面复选框,那么物理引擎将自动简化它,生成一个具有最近凸面形状的碰撞体。

在前面的例子中,如果我们导入右侧的凹面网格并启用凸面复选框,它将生成一个更接近左侧凸面形状的碰撞体形状。在任何情况下,物理引擎都将尝试生成一个与附加网格形状匹配的碰撞体,其顶点数上限为 255。如果目标网格的顶点数超过这个数,在网格生成过程中将抛出错误。

Collider组件还包含IsTrigger属性,允许它们被视为非物理对象,但在其他碰撞体进入或离开它们时仍然可以触发物理事件。这些被称为触发体积。通常,当另一个碰撞体接触、保持接触(每个时间步)或停止接触时,分别调用碰撞体的OnCollisionEnter()OnCollisionStay()OnCollisionExit()回调。然而,当碰撞体用作触发体积时,将使用OnTriggerEnter()OnTriggerStay()OnTriggerExit()回调。

注意,由于解决物体间碰撞的复杂性,凹面网格碰撞体不能也是动态碰撞体。凹面形状只能用作静态碰撞体或触发体积。如果我们尝试向凹面网格碰撞体添加Rigidbody组件,Unity 将忽略它。

假设你真正需要一个作为Rigidbody组件使用的凹面网格碰撞体?解决方案是将对象分解为多个独立的凸面网格碰撞体的组合:例如,你可能想通过组合两个凸面盒子来创建一个 L 形的Rigidbody。不幸的是,因为这是一个微妙的决策,没有自动化的方法来做这件事,你需要手动进行这种分解。

碰撞矩阵

物理引擎具有一个碰撞矩阵,它定义了哪些对象可以与哪些其他对象发生碰撞。当需要解决边界体积重叠和碰撞时,不符合此矩阵的对象将被物理引擎自动忽略。这有助于在碰撞检测阶段节省物理处理时间,并允许物体相互移动而不会发生任何碰撞。

可以通过“编辑 | 项目设置 | (物理 / 物理 2D)| 层碰撞矩阵”访问碰撞矩阵。

碰撞矩阵系统通过 Unity 的层系统工作。矩阵代表可能出现的每个层到层组合,勾选复选框意味着在碰撞检测阶段将检查这两个层中的碰撞体。请注意,没有方法可以仅允许两个对象中的一个响应碰撞。如果一个层可以与另一个层碰撞,那么它们都必须响应碰撞。然而,静态碰撞体是一个例外,因为它们不允许物理上对碰撞做出反应(尽管它们仍然会接收到OnCollision...()回调)。

注意,我们整个项目只能使用 32 个总层(因为物理引擎使用 32 位掩码来确定层间碰撞机会),因此我们必须将对象组织成合理的层,这些层将贯穿整个项目的生命周期。如果出于任何原因,32 个层不足以满足我们的项目需求,那么我们可能需要找到巧妙的方法来重复使用层或删除不必要的层。

Rigidbody的活跃和休眠状态

每个现代物理引擎都共享一个标准的优化技术,即静止的物体将它们的内部状态从活跃状态更改为休眠状态。当Rigidbody处于休眠状态时,在固定更新期间将不会花费处理器时间来更新对象,直到外力或碰撞事件将其唤醒。

用于确定静止状态值的测量值在不同物理引擎中往往有所不同;它可以使用线性速度、角速度、动能、动量或其他Rigidbody的物理属性来计算。Unity 的两个物理引擎都是通过评估物体的质量归一化动能来工作的,这本质上等同于其速度大小的平方。

如果物体在短时间内速度没有超过某个阈值,那么物理引擎将假设物体将不再需要移动,直到它经历了新的碰撞,或者对其施加了新的力。在此之前,休眠的物体将保持其当前位置。设置阈值过低意味着物体不太可能进入休眠状态,因此我们将在物理引擎的每次固定更新中持续支付少量的处理成本,即使它没有做任何重要的事情。同时,设置阈值过高意味着缓慢移动的物体在物理引擎决定它们需要进入休眠状态时,会突然停止。控制休眠状态的阈值可以在“编辑 | 项目设置 | 物理 | 休眠阈值”下修改。我们还可以从 Profiler 窗口的物理区域获取活动Rigidbody对象的总数。

注意,休眠物体并没有完全从模拟中移除。如果一个移动的Rigidbody接近休眠物体,那么它仍然必须执行检查,以确定附近物体是否与之碰撞,这将重新唤醒休眠物体,将其重新引入模拟以进行处理。

射线和物体投射

另一个物理引擎的常见特性是能够从一个点到另一个点投射一条射线,并与其路径上的一个或多个物体生成碰撞信息。这被称为射线投射。通过射线投射实现几个游戏机制是很常见的,例如开枪。这通常是通过从玩家到目标位置执行射线投射,并找到其路径上的任何有效目标(即使只是一个墙壁)来实现的。

我们还可以使用Physics.OverlapSphere()检查在空间中固定点有限距离内的目标列表。这通常用于实现区域效果游戏特性,例如手榴弹或火球爆炸。我们甚至可以使用Physics.SphereCast()Physics.CapsuleCast()将整个物体向前投射。这些方法通常用于模拟宽激光束,或者如果我们想看看移动角色的路径上会有什么。

调试物理

物理错误通常分为两类:一个物体对在它不应该发生碰撞时发生了碰撞/没有发生碰撞,或者物体发生了碰撞,但在之后发生了意料之外的事情。前者通常更容易调试;这通常是由于碰撞矩阵中的错误、在光线投射中使用的不正确的层,或者物体碰撞器的尺寸或形状不正确。后者通常由于三个大问题而更具挑战性:

  • 确定哪些碰撞物体导致了问题

  • 确定碰撞解决前的碰撞条件

  • 重新模拟碰撞

这三部分信息中的任何一部分都会使问题解决变得容易得多,但在某些情况下,它们都很难获得。

分析器在物理和物理(二维)区域(分别对应 3D 和 2D 物理)提供了一些信息量,这可以是有一定帮助的。我们可以了解所有刚体和不同类型(如动态碰撞体、静态碰撞体、运动学对象、触发体积、约束(用于模拟铰链和其他连接的物理对象)和接触)的刚体组在 CPU 活动上花费了多少时间。

物理二维区域包含一些额外的信息,例如休眠和活动刚体数量以及处理时间步长所需的时间。详细分解视图在这两种情况下都提供了更多信息。这些信息有助于我们关注物理性能,但如果我们发现物理行为中存在错误,它并不能告诉我们太多关于出错原因的信息。

一个更适合帮助我们调试物理问题的工具是物理调试器,可以通过“窗口”|“分析”|“物理调试器”打开。这个工具可以帮助我们从场景窗口中过滤出不同类型的碰撞体,从而更好地了解哪些对象相互碰撞。当然,这并不能太多地帮助我们确定问题的条件并重现问题。

注意,物理调试器中的设置不会影响游戏窗口中的对象可见性。

很遗憾,对于剩余的问题,没有太多秘密建议可以提供。在碰撞发生之前或发生时捕捉有关碰撞的信息通常需要在OnCollisionEnter()OnTriggerEnter()回调中设置许多目标断点,以捕捉问题发生时的状态,并使用单步调试直到问题的根源变得明显。作为最后的手段,我们可以在问题发生之前添加Debug.Log()语句来记录重要信息,尽管这可能是一项令人沮丧的练习,因为我们有时不知道需要记录哪些信息,或者从哪些对象中记录,因此我们最终将日志添加到所有内容中。

另一个常见的头痛来源是尝试重现物理问题。由于用户输入(通常在Update()中处理)和物理行为(在FixedUpdate()中处理)之间的非确定性,重现碰撞始终是一个挑战。尽管物理时间步长发生相对规律,但模拟在每个Update()会话之间会有不同的时间,因此,如果我们记录了用户输入时间并自动回放场景,试图在输入应用的时刻应用记录的输入并不总是完全相同,因此我们可能不会得到相同的结果。

将用户输入处理移动到FixedUpdate()是可能的,如果用户输入控制Rigidbody行为,例如在玩家按下某些键的同时施加不同方向的力,这将是很有帮助的。然而,这往往会引起输入延迟或滞后,因为物理引擎对按键响应需要从 0 到 20 毫秒(基于固定更新时间步频率)的时间。瞬间的输入,如跳跃或激活能力,始终最好在Update()中处理,以避免

缺少的按键。

辅助函数,如Input.GetKeyDown(),仅在玩家按下指定键的帧返回true,在下一个Update()期间返回false。如果我们试图在FixedUpdate()期间读取键按下事件,除非恰好在这两个帧之间发生物理时间步,否则我们永远不会知道用户是否按下了键。这可以通过输入缓冲/跟踪系统来解决,但如果仅仅是为了复制物理错误而实现它,这无疑是一个麻烦大于其价值的事情。

最终,经验和坚持是解决大多数物理问题的唯一正确途径。我们对物理引擎了解得越多,找到问题根源的直觉就越强,但遗憾的是,由于它们的可重复性有限,有时行为模糊不清,解决这些问题几乎总是需要花费大量时间,因此我们应该预期物理问题修复所需的时间会比大多数逻辑错误修复的时间更长,并在解决问题之前预留额外的时间。

现在我们已经了解了 Unity 物理引擎的大多数功能,我们可以介绍一些优化技术来提高我们游戏中的物理性能。

物理性能优化

在本节中,我们将介绍几种技术、优化、技巧和设置,这些可以帮助您的游戏从游戏中提取每一滴物理性能。这包括如何设置场景、何时使用静态碰撞器、如何配置碰撞矩阵、何时使用触发器而不是刚体,以及更多。让我们逐一介绍所有这些内容。

场景设置

首先,我们可以应用一些最佳实践来提高我们场景中物理模拟的一致性。请注意,这些技术中的几个不一定能提高 CPU 或内存使用,但它们将降低物理引擎不稳定性的可能性。

缩放

我们应该尽量将世界中的所有物理对象比例保持得尽可能接近 (1,1,1)。默认情况下,Unity 假设我们正在尝试模拟与地球表面相似的游戏玩法。地球表面的重力是每秒 9.81 米,因此默认的重力值设置为 -9.81 以匹配。Unity 世界空间中的一个单位相当于 1 米,负号表示它将拉动对象向下。我们的对象大小应该反映我们的有效世界比例,因为如果它们太大,重力看起来会移动对象比我们预期的要慢得多。如果我们的所有对象都放大了五倍,那么重力看起来会弱五倍。相反的情况也是真实的;对象缩放得太小会使它们看起来下落得太快,并且看起来不真实。

我们可以通过修改“编辑 | 项目设置 | 物理 / 2D 物理 | 重力”下的重力强度来调整世界的隐含比例。然而,请注意,任何浮点运算在值接近 0 时将更加准确,因此如果我们有一些比例值远高于 (1,1,1) 的对象,即使它们与隐含的世界比例相匹配,我们仍然可能会观察到不规则的物理行为。因此,在项目早期,我们应该将最常用的物理对象导入并围绕比例值 (1,1,1) 进行缩放,然后调整重力值以匹配。这将为我们引入新对象时提供一个参考点。

定位

类似地,将所有对象在世界的空间位置上保持接近 (0,0,0) 将导致更好的浮点精度,从而提高模拟的一致性。空间模拟器和自由运行的游戏试图模拟极其巨大的空间,通常使用一种秘密将玩家传送到世界中心的技巧,或者固定他们的位置,在这种情况下,要么将空间体积分成几个部分,以便物理计算始终使用接近 0 的值,要么将其他所有东西移动以模拟旅行,而玩家的运动只是一个幻觉。

大多数游戏不会引入浮点不准确性,因为大多数游戏关卡通常持续大约 10 到 30 分钟,这不会给玩家太多时间进行荒谬的长距离旅行,但如果我们正在处理特别大的场景,或者在整个游戏过程中异步加载场景,以至于玩家旅行了数万米,那么我们可能会开始注意到一些奇怪的物理行为,随着他们走得更远。

因此,除非我们已经非常深入到我们的项目中,以至于在后期阶段更改和重新测试一切会变得过于麻烦,我们应该尽量将所有物理对象保持接近 (0,0,0)。此外,这对于我们的项目工作流程来说也是良好的实践,因为它使得在游戏世界中查找对象和调整事物变得更快。

质量

质量作为浮点值存储在Rigidbody组件的质量属性下,由于其物理引擎的更新,关于其使用的文档在多年中发生了相当大的变化。自 Unity 5 后期以来,我们基本上可以自由选择1.0值代表什么,然后按比例调整其他值。

适当地分配值。

传统上,质量值为1.0用来表示 1 千克的质量,但我们可以决定一个人类的质量为1.0(约 80 千克),在这种情况下,汽车的质量值将为15.0(约 1,200 千克),物理碰撞将类似于我们预期的结果。最重要的是相对质量差异,这允许这些对象之间的碰撞看起来逼真,而不会过度压力引擎。浮点精度也是一个考虑因素,因此我们不想使用过于荒谬的大质量值。

注意,如果我们打算使用轮式碰撞体,它们的设计假设质量为1.0代表 1 千克,因此我们应该适当地分配我们的质量值。

理想情况下,我们应该保持质量值在1.0左右,并确保最大相对质量比大约为100。如果两个物体的质量比远高于这个值,那么大的动量差异可能会因为冲量而突然转变为巨大的速度变化,导致一些不稳定的物理现象和潜在的浮点精度损失。具有显著比例差异的对象对可能需要通过碰撞矩阵进行剔除以避免问题(关于这一点稍后还会讨论)。

不恰当的质量比是 Unity 中物理不稳定性和异常行为最常见的原因。这在使用关节连接如布娃娃等对象时尤其如此。

注意,地球中心的引力对所有物体的影响是相同的,无论其质量如何,因此我们是否将质量属性值1.0视为橡皮球的重量或战舰的重量并不重要。没有必要调整引力来补偿。然而,重要的是,给定物体在下落过程中所经历的空气阻力(这就是为什么降落伞会缓慢下落)。因此,为了保持逼真的行为,我们可能需要为这类对象自定义阻力属性或根据每个对象自定义引力。例如,我们可以禁用“使用重力”复选框,并在固定更新期间应用我们的自定义引力。

适当地使用静态碰撞体

如前所述,物理引擎会自动生成两个独立的数据结构来分别存储静态碰撞体和动态碰撞体。不幸的是,如果在运行时向静态碰撞体数据结构中引入新对象,那么它必须被重新生成,类似于调用StaticBatchingUtility.Combine()进行静态批处理。这很可能会导致 CPU 使用率显著上升。因此,在游戏过程中避免实例化新的静态碰撞体变得至关重要。

此外,仅仅移动、旋转或缩放静态碰撞体也会触发此再生过程,应避免这样做。如果我们希望移动碰撞体而无需物理上对其与其他对象的碰撞做出反应,则应附加 Rigidbody 以使其成为动态碰撞体并启用运动学标志。此标志防止对象对外部冲量做出反应,类似于静态碰撞体,但对象仍然可以通过其 transform 组件或通过施加到其 Rigidbody 组件上的力(最好在固定更新期间)进行移动。由于运动学对象不会对其他对象的撞击做出反应,因此它在移动时会倾向于将其他动态碰撞体推开。

正是因为这个原因,玩家角色对象通常会被制作成运动学碰撞体。

负责任地使用触发体积

如前所述,我们可以将我们的物理对象视为普通碰撞体或触发体积。这两种类型之间的重要区别是,OnCollider...() 回调函数将一个 Collision 对象作为参数传递给回调函数,其中包含有用的信息,例如碰撞的确切位置(有助于定位粒子效果)和接触法线(如果我们想在碰撞后手动移动对象,则很有用)。然而,OnTrigger...() 回调函数不提供此类信息。

因此,我们不应该尝试使用触发体积来实现碰撞反应行为,因为我们不会有足够的信息来使碰撞看起来准确。触发体积最好用于其预期目的,即跟踪对象何时进入/离开特定区域,例如,当玩家停留在熔岩坑中处理伤害,当玩家进入建筑时触发一个场景,以及当玩家接近/远离另一个主要区域时启动/卸载场景。

如果绝对需要接触信息来进行触发体积碰撞,则常见的解决方案包括以下任何一种:

  • 通过将触发体积和碰撞对象质心之间的距离减半来生成接触点的粗略估计(这假设它们的大小大致相等)。

  • 从触发体积的中心向碰撞对象的质心进行射线投射(如果两个对象都是球形的,效果最佳)。

  • 创建一个非触发体积对象,给它一个无限小的质量(这样它的存在几乎不会影响碰撞对象),并在碰撞时立即销毁它(因为与如此大的质量差异的碰撞可能会将这个小物体送入轨道)。

当然,每种方法都有其缺点——有限的物理准确性、碰撞期间的额外 CPU 开销和/或额外的场景设置(以及看起来相当奇怪的碰撞代码),但在紧急情况下它们可能很有用。

优化碰撞矩阵

如我们所知,物理引擎的碰撞矩阵定义了分配给特定层的对象允许与分配给其他层的对象发生碰撞。更简洁地说,哪些对象碰撞对被物理引擎认为是可行的。其他所有对象-层对都被物理引擎简单地忽略,这使得这是一个减少物理引擎工作负载的重要途径,因为它减少了每个固定更新必须执行的边界体积检查的数量,以及在整个应用程序生命周期中需要处理的碰撞数量(这将为移动设备节省电池寿命)。

注意,可以通过“编辑”|“项目设置”|“物理”(或“物理 2D”)|“层碰撞矩阵”访问碰撞矩阵。

下面的截图显示了一个街机射击游戏的典型碰撞矩阵:

在前面的例子中,我们标记了对象为玩家、敌人、玩家导弹、敌人导弹和道具,并且我们已经最小化了物理引擎需要检查的可能对象间碰撞的数量。

从标记为玩家的第一行勾选开始,我们希望玩家对象能够与世界对象发生碰撞,拾取道具,被敌人导弹击中,并与敌人发生碰撞。然而,我们不希望它们与自己的玩家导弹或自身发生碰撞(尽管在这个层中可能只有一个对象)。因此,玩家行中启用的复选框反映了这些要求。我们只想让敌人与世界对象和玩家导弹发生碰撞,所以在敌人行中勾选了这些。请注意,玩家到敌人的碰撞对已经在上一行中处理过了;因此,没有必要在敌人行中再次出现。我们还希望玩家导弹和敌人导弹在击中世界对象时爆炸,所以这些被标记了,最后我们不在乎道具与玩家以外的任何东西发生碰撞,也不希望世界对象与其他世界对象发生碰撞,所以在最后两行没有勾选复选框。

在任何给定时刻,我们可能只有一个玩家对象,2 个增益道具,7 个玩家导弹,10 个敌人,和 20 个敌人导弹,这共有 780 个潜在的碰撞对(这是计算出来的,因为 40 个不同的对象中的每一个都可以与 39 个其他对象发生碰撞,从而产生 1,560 个可能的碰撞对,但然后我们将总数除以 2 以忽略重复的对)。仅仅优化这个矩阵,我们就将其减少到不到 100,这意味着潜在的碰撞检查减少了近 90%。当然,Unity 物理引擎会有效地剔除掉很多这些对象对,如果它们彼此之间距离太远。因此,几乎没有机会它们会碰撞(这是在称为粗略阶段剔除的隐藏过程中计算的),所以实际节省的可能会好得多,但它几乎不需要任何努力就能释放一些 CPU 周期。另一个显著的好处是它简化了我们的游戏逻辑编码;如果我们告诉物理引擎忽略增益道具和敌人导弹之间的碰撞,我们就不需要弄清楚如果它们发生碰撞会发生什么。

我们应该对所有潜在的层组合在碰撞矩阵中进行逻辑合理性检查,以查看我们是否在浪费宝贵的时间检查那些不必要的对象对之间的对象间碰撞。

倾向于使用离散碰撞检测

离散碰撞检测相对便宜,因为将对象传送一次并在单个时间步长内执行附近对象对之间的单个重叠检查是一项相对简单的工作。执行连续碰撞检测所需的计算量显著更高,因为它涉及到在分析这些点之间可能发生的任何轻微边界体积重叠的同时,对两个对象在起始位置和结束位置之间的插值。

因此,连续碰撞检测选项比离散检测方法贵一个数量级,而连续动态碰撞检测设置甚至比连续碰撞检测更昂贵。如果配置了太多对象使用任何一种连续碰撞检测类型,将会在复杂场景中导致严重的性能下降。在两种情况下,碰撞检测的成本都会随着任何给定帧中需要比较的对象数量呈指数增长,如果碰撞体是动态的而不是静态的,增长将更为陡峭。

因此,我们应该为绝大多数对象优先选择离散碰撞检测设置,而仅在极端情况下使用任一连续碰撞检测设置。当游戏世界的静态部分频繁错过重要碰撞时,应使用连续设置。例如,如果我们希望确保玩家角色永远不会从游戏世界中掉落,或者如果他们移动得太快,永远不会意外地通过墙壁传送,那么我们可能只想为这些对象应用连续碰撞检测。最后,如果相同的情况适用,并且我们希望捕捉到非常快速移动的动态碰撞器之间的碰撞,则应仅使用 ContinuousDynamic 设置。

修改固定更新频率

在某些情况下,离散碰撞检测在大规模上可能工作得不够好。也许我们的整个游戏都围绕着大量的小型物理对象,而离散碰撞检测根本无法捕捉到足够的碰撞以维持产品质量。然而,将连续碰撞检测设置应用于所有内容将对性能产生极大的负面影响。在这种情况下,我们可以尝试一个选项:我们可以自定义物理时间步长,通过修改引擎检查固定更新的频率,给离散碰撞检测系统更好的机会捕捉到这种碰撞。

如前所述,固定更新和物理时间步长处理紧密相关;因此,通过修改固定更新检查的频率,我们不仅改变了物理引擎计算和解决下一个回调的速率,还改变了FixedUpdate()回调和协程被调用的频率。因此,如果我们已经深入到项目中并且有很多依赖于这些回调的行为,那么更改此值可能会有风险,因为我们将会改变关于这些方法调用频率的基本假设。

使用编辑器中的“编辑 | 项目设置 | 时间 | 固定时间步长”属性或通过脚本代码中的Time.fixedDeltaTime属性,可以更改固定的更新频率。

减少此值(增加频率)将迫使物理引擎更频繁地处理,从而有更好的机会通过离散碰撞检测捕捉到碰撞。当然,这会带来额外的 CPU 成本,因为我们正在调用更多的FixedUpdate()回调,并要求物理引擎更频繁地更新,使其更频繁地移动对象和验证碰撞。

相反,增加这个值(降低频率)为 CPU 提供了更多时间在再次处理物理处理之前完成其他任务,或者从另一个角度来看,它给了物理引擎更多时间在开始处理下一个时间步之前处理最后一个时间步。不幸的是,降低固定更新频率将不可避免地减少物体在物理引擎无法再通过离散碰撞检测捕获碰撞之前可以移动的最大速度(取决于物体的尺寸)。我们可能还会看到物体以奇怪的方式改变速度,因为这本质上是对现实世界物理行为的较弱近似。

这使得每次更改固定时间步长值时进行大量测试变得至关重要。即使完全理解这个值的工作原理,也很难预测游戏过程中的整体结果以及结果是否在质量方面可以接受。因此,这个值的更改应该在项目的早期生命周期中进行,并且应该很少更改,以便尽可能多地针对尽可能多的物理情况进行测试。

创建一个测试场景,让一些高速物体相互碰撞,以验证结果是否可接受,并在每次固定时间步长变化时运行这个场景可能会有所帮助。然而,实际的游戏玩法往往相当复杂,有许多后台任务和未预见的玩家行为,这会给物理引擎带来额外的工作,或者给它更少的时间来处理当前迭代。在真空中无法复制实际游戏玩法条件。此外,没有替代品可以替代真实的事物,因此,我们可以在当前固定时间步长的值上进行更多测试,这样我们就可以更有信心地认为这些变化符合可接受的质量标准。

以一个从事软件开发自动化工具开发的人的经验来看:在许多情况下,软件测试的自动化是有帮助的,但当涉及到与多个硬件设备和复杂子系统(如物理引擎)同步的实时事件和用户输入驱动的应用程序时,这些子系统由于反馈的迭代而倾向于快速变化,自动化测试的支持和维护成本往往比其价值更大,这使得手动测试成为最合理的途径。

我们总是将连续碰撞检测作为最后的手段来抵消我们观察到的某些结果不稳定。不幸的是,即使这些变化是针对特定目标的,由于连续碰撞检测的开销成本,这很可能会比我们开始时造成更多的性能问题。在启用连续碰撞检测之前和之后对场景进行性能分析,以验证收益是否超过了成本,这将是一个明智的做法。

调整最大允许时间步长

如果我们经常超过最大允许时间步长(提醒一下,这决定了物理引擎在必须提前退出之前可以解决多少时间步长),那么将会导致一些相当奇怪的物理行为。刚体看起来会减速或突然停止,因为物理引擎需要在完全解决整个时间配额之前,多次提前退出时间步计算。在这种情况下,这是一个明显的迹象,表明我们需要从其他角度优化我们的物理行为。然而,至少我们可以确信,这个阈值将防止游戏在物理处理过程中中间出现峰值时完全锁定。

提醒:此设置可通过编辑 | 项目设置 | 时间 | 最大允许时间步长访问。

默认设置是消耗最多 0.333 秒,如果超过这个值,将会表现为帧率明显下降(仅为 3 FPS)。如果你觉得需要更改此设置,那么显然你的物理工作负载存在一些大问题,因此建议你只有在用尽所有其他方法后,才调整此值。

最小化射线投射和边界体积检查

所有射线投射方法都非常有用,但它们相对昂贵,尤其是CapsuleCast()SphereCast()。我们应该避免在Update()回调或协程中定期调用这些方法,只为脚本代码中的关键事件保留它们。

如果我们在场景中使用持久线、射线或区域碰撞区域(例如安全激光、持续燃烧的火焰和光束武器),并且对象相对静止,那么使用简单的触发体积模拟可能更好。

如果这种替换不可行,并且我们确实需要使用这些方法进行持久的投射检查,我们应该通过利用LayerMasks来最小化每个射线投射的处理量。这尤其适用于我们使用Physics.RaycastAll()的情况。例如,这种射线投射的糟糕优化可能如下所示:

void PerformRaycast() {
 RaycastHit[] hits;
  hits = Physics.RaycastAll(transform.position, transform.forward, 
  100.0f);
  for (int i = 0; i < hits.Length; ++i) {
    RaycastHit hit = hits[i];
    EnemyComponent e = hit.transform.GetComponent<EnemyComponent>();
    if (e.GetType() == EnemyType.Orc) {
        e.DealDamage(10);
    }
  }
}

在前面的例子中,我们正在收集射线投射碰撞数据,针对这条射线路径上的每个对象,但我们只处理对具有特定EnemyComponent实例的对象的影响。因此,我们要求物理引擎完成比必要多得多的工作。

更好的方法是使用RaycastAll()的不同重载,它接受一个作为参数的LayerMask值。这将以与碰撞矩阵相同的方式过滤射线的碰撞,以便它只测试给定层(s)中的对象。以下代码通过提供额外的LayerMask属性进行细微的改进;我们将通过检查器窗口为此组件配置LayerMask,这将更快地过滤列表,并且只包含与掩码匹配的hits

[SerializeField] LayerMask _layerMask;

void PerformRaycast() {
  RaycastHit[] hits;
  hits = Physics.RaycastAll(transform.position, transform.forward, 100.0f, _layerMask);
  for (int i = 0; i < hits.Length; ++i) {
    // as before ...
  }
}

这种优化对Physics.RaycastHit()函数的效果并不好,因为该版本只提供射线与第一个碰撞对象的碰撞信息,无论我们是否使用LayerMask

注意,由于RaycastHitRay类由 Unity 引擎的本地内存空间管理,它们不会导致垃圾收集器注意到的内存分配。我们将在第八章“精湛的内存管理”中了解更多关于此类活动的内容。

避免复杂的网格碰撞体

按照碰撞检测效率的顺序,各种碰撞体是球体、胶囊体、盒子、凸网格碰撞体和凹网格碰撞体,其中最后一种是成本最高的。碰撞总是涉及成对的物体,解决碰撞所需的工作(数学运算)将取决于两个物体的复杂性。检测两个原始物体的碰撞可以简化为一系列相对简单的数学方程,这些方程经过了高度优化。对一对凸网格碰撞体进行比较的方程要复杂得多,这使得它们比两个原始物体之间的碰撞贵一个数量级。然后,还有两个凹网格碰撞体之间的碰撞,它们如此复杂,以至于不能简化为简单的公式,需要在每个网格的每对三角形之间进行碰撞检查,这使得它们比其他碰撞体类型的碰撞贵许多数量级。当我们解决不同组形状之间的碰撞时,涉及的工作量以类似的方式缩放。例如,原始物体与凹网格碰撞体之间的碰撞会比两个原始物体之间的碰撞慢,但会比两个凹网格碰撞体之间的碰撞快。

还有一个问题,即参与碰撞的物体中是否有一个或两个正在移动(如果一个物体是静态碰撞体,处理起来比两个都是动态碰撞体要容易)。还有一点是,场景中有多少这样的物体,因为如果我们不小心引入到模拟中的形状数量,碰撞检测的总处理成本将会激增。

在 3D 应用程序中,表示物理和图形之间的一个巨大讽刺是如何难以在这两者之间处理球形和立方体对象。完美的球形网格需要生成无限数量的多边形,这使得这样的对象在图形上无法表示。

然而,在物理引擎中处理两个球体之间的碰撞可能是解决接触点和碰撞的最直接问题(接触点始终位于任一球体半径的边缘,接触法线始终是它们质心之间的向量)。相反,立方体是图形上表示最简单的对象之一(只需 8 个顶点和 12 个三角形),但要找到接触点和解决碰撞(解决这些碰撞的数学取决于碰撞是发生在面、边、角还是混合配对之间)需要显著更多的数学和计算能力。据观察,这表明创建最大数量对象的最有效方法可能是用使用球形碰撞器的立方体对象来填充我们的世界。然而,这对人类观察者来说完全没有意义,因为他们会看到立方体像球一样滚动。

之前的例子提醒我们,一个对象的物理表示不一定需要与其图形表示相匹配。这是有益的,因为图形网格通常可以简化为更简单的形状,同时仍然产生非常相似的物理行为,并同时消除使用过于复杂的 Mesh Collider 的需求。

这种在图形和物理之间分离表示的方法使我们能够在不(必然)负面影响另一个系统的情况下优化一个系统的性能。只要没有对游戏玩法产生明显的负面影响(或者我们愿意做出牺牲),我们就自由地用更简单的物理形状来表示复杂的图形对象,而玩家不会注意到。此外,如果玩家从未注意到,那么就不会造成任何伤害。

因此,我们可以通过以下两种方式之一解决这个问题:要么通过使用一个(或多个)标准原语来近似复杂形状的物理行为,要么使用一个更简单的 Mesh Collider。

使用更简单的原语

大多数形状都可以使用三种基本碰撞器之一来近似。实际上,我们不需要仅使用单个碰撞器来表示对象。如果它们有助于我们通过附加具有碰撞器的额外子 GameObject 来创建复杂的碰撞形状,我们可以自由地使用多个碰撞器。这几乎总是比使用单个 Mesh Collider 更经济,应该优先考虑。

以下截图显示了由一个或多个更简单的原语碰撞器形状表示的一组复杂图形对象:

图片

对于这些对象中的任何一个使用网格碰撞器,由于它们包含的多边形数量,其成本将显著高于这里显示的原始碰撞器。值得探索所有机会,尽可能使用这些原始形状简化我们的对象,因为它们可以提供显著的性能提升。

例如,凹面网格碰撞器是独特的,因为它们可以具有间隙或孔洞,允许其他网格落入甚至穿过它们,这为物体通过使用此类碰撞器作为世界碰撞区域提供了机会。在这种情况下,通常将盒形碰撞器放置在战略位置会更好。

使用更简单的网格碰撞器

同样,分配给网格碰撞器的网格不一定需要与同一对象的图形表示相匹配(Unity 只是将其作为默认选择)。这允许我们将一个更简单的网格分配给网格碰撞器的mesh属性,这与我们用于其图形表示的网格不同。

以下截图显示了一个复杂的图形网格,其网格碰撞器已被赋予了一个更简化的网格:

图片

以这种方式将渲染网格简化为具有较低多边形计数的凸形状将显著减少确定与其他碰撞器边界体积重叠所需的开销。根据原始对象估计的好坏,游戏体验上的差异应该是最小的,尤其是在这个斧头的情况下,我们预计它将在攻击时快速移动,使得玩家不太可能注意到两种网格作为碰撞器之间的差异。实际上,简化后的网格不太可能被离散碰撞检测忽略,因此出于这个原因更可取。

避免复杂的物理组件

某些特殊的物理Collider组件,如TerrainColliderClothWheelCollider,在成本上比所有原始碰撞器甚至某些情况下的网格碰撞器高得多。除非绝对必要,否则我们不应在我们的场景中包含此类组件。例如,如果我们有玩家永远不会接近的远处的地形对象,那么包含附加的TerrainCollider组件就几乎没有理由。

拥有Cloth组件的游戏在运行在低质量设置时应考虑实例化不带它们的对象,或者简单地动画化布料行为(尽管如果团队已经对布料移动的方式产生了感情并爱上了它,这是完全可以理解的)。

使用WheelCollider组件的游戏应尽量减少轮轴碰撞器的使用。大型车辆,如果拥有超过四个轮子,可能仅用四个轮子就能模拟出类似的行为,同时通过图形上模拟额外的轮子来欺骗视觉效果。

让物理对象休眠

物理引擎的休眠功能可能会给我们的游戏带来几个问题。首先,一些开发者没有意识到他们的大多数刚体在应用程序的大部分生命周期中都是处于休眠状态的。这往往导致开发者认为他们可以通过(例如)将游戏中的刚体数量加倍来逃避问题,整体成本也会简单地加倍以匹配。这不太可能。碰撞频率和活动对象的总累积时间更有可能呈指数增长而不是线性增长。每次向模拟中引入新的物理对象时,都会导致意外的性能成本。当我们决定增加场景的物理复杂性时,我们应该牢记这一点。

第二,在运行时更改Rigidbody组件上的任何属性,例如质量、阻力和useGravity,也会唤醒一个对象。如果我们经常更改这些值(例如,对象大小和质量随时间变化的游戏),那么它们将比通常情况下保持更长时间的活动状态。应用力也是如此,因此如果我们使用自定义重力解决方案(例如在质量部分建议的),我们应该尽量避免在每次固定更新时应用重力;否则,对象将无法进入休眠状态。我们可以检查其质量归一化的动能(只需取velocity.sqrMagnitude的值)并在检测到它非常低时手动禁用我们的自定义重力。

第三,存在生成休眠物理对象孤岛的危险。当大量刚体相互接触并且系统动能降低到足够低时,孤岛就会形成。然而,由于它们仍然相互接触,一旦这些对象中的任何一个被唤醒,就会引发连锁反应,唤醒所有附近的刚体。突然之间,CPU 使用量会急剧上升,因为数十个对象重新进入了模拟。更糟糕的是,由于对象非常接近,将会有许多潜在的碰撞对需要不断解决,直到对象再次进入休眠状态。

避免这些情况的最佳做法是简化我们的场景复杂性,但如果发现自己无法做到这一点,我们可以寻找检测岛屿形成的方法,然后有策略地销毁/解散其中一些,以防止生成太多大型岛屿。然而,在所有刚体之间执行定期的距离比较并不是一项容易完成的任务,可能会很昂贵。物理引擎已经在广相剔除过程中自行执行此类检查,但不幸的是,Unity 并没有通过物理引擎 API 提供这些数据。任何针对此问题的解决方案都将取决于游戏的设计;例如,需要玩家将许多物理对象移动到某个区域(例如,需要将羊群赶到围栏的游戏)的游戏可以选择在玩家将羊移动到位置后立即移除羊的碰撞器,将对象锁定在其最终目的地,减轻物理引擎的工作负担,防止岛屿成为问题。

睡眠对象可能既是祝福也是诅咒。它们可以为我们节省大量的处理能力,但如果太多对象同时苏醒,或者我们的模拟过于繁忙,无法让足够多的对象进入睡眠状态,那么在游戏过程中可能会产生一些不幸的性能成本。我们应该尽可能限制这些情况,让我们的对象尽可能进入睡眠状态,并避免将它们分组成大型集群。

注意,可以在“编辑”|“项目设置”|“物理”|“睡眠阈值”下修改睡眠阈值。

修改求解器迭代次数

使用关节、弹簧和其他方式连接刚体在物理引擎中是相当复杂的模拟。由于将两个对象连接在一起而产生的相互依赖的交互性(内部表示为运动约束),系统必须经常尝试解决必要的数学方程。这种多迭代方法在对象链的任何部分发生速度变化时,都需要计算准确的结果。

因此,这变成了一种权衡,即在求解器尝试解决特定情况的最大次数与我们可以接受的准确度之间进行权衡。我们不希望求解器在单个碰撞上花费太多时间,因为物理引擎在同一迭代中还有许多其他任务要完成。然而,我们也不希望将最大迭代次数减少太多,因为这只会近似最终解决方案,使其运动看起来远不如如果给它更多时间计算结果那样可信。

在解决对象间碰撞和接触时,相同的求解器也会被涉及。它几乎总是可以通过单次迭代确定简单碰撞的正确结果,除了与网格碰撞器的一些非常罕见且复杂的碰撞情况。大多数情况下,当附加对象将通过关节受到影响时,求解器需要额外的努力来整合最终结果。

求解器允许尝试的最大迭代次数被称为求解器迭代次数,可以在“编辑 | 项目设置 | 物理 | 默认求解器迭代次数”下进行修改。在大多数情况下,默认的六次迭代值是完全可接受的。然而,包含非常复杂的关节系统的游戏可能希望增加此计数以抑制任何不规则的(或直接爆炸性的)CharacterJoint行为,而某些项目可能可以通过减少此计数来避免。更改此值后必须进行测试,以检查项目是否仍然保持预期的质量水平。请注意,此值是默认求解器迭代次数——应用于任何新创建的刚体的值。我们可以在运行时通过 Physics.defaultSolverIterations 属性更改此值,但这仍然不会影响现有的刚体。如果需要,我们可以在它们构建后通过 Rigidbody.solverIterations 属性修改它们的求解器迭代次数。

如果我们发现在游戏中经常遇到由于复杂的基于关节的对象(例如布娃娃)而导致的令人不快的、不规则的、违反物理规律的情景,那么我们应该考虑逐渐增加求解器的迭代次数,直到这些问题被抑制。这些问题通常发生在我们的布娃娃从碰撞对象中吸收了过多的能量,而求解器在被迫放弃之前无法迭代出合理的解决方案。在这种情况下,其中一个关节会发生超新星爆炸,将其他关节也拖入轨道。Unity 为此问题提供了一个单独的设置,可以在“编辑 | 项目设置 | 物理 | 默认求解器速度迭代次数”下找到。增加此值将给求解器更多的机会在基于关节的对象碰撞期间计算合理的速度,并有助于避免上述情况。再次强调,这是一个默认值;因此,它仅适用于新创建的刚体。此值可以通过 Physics.defaultSolverVelocityIterations 属性在运行时修改,并且可以通过 Rigidbody.solverVelocityIterations 属性在特定的刚体上自定义。

在任何情况下,增加迭代次数都会在关节对象保持活跃的每次固定更新期间消耗更多的 CPU 资源。

注意,Physics 2D 的求解器迭代设置被命名为“位置迭代”和“速度迭代”。

优化布娃娃

谈及基于关节的对象,ragdolls(布娃娃)因其独特的魅力而广受欢迎,这并非没有原因;它们非常有趣!暂且不提在游戏世界中抛掷尸体所带来的恐怖感,观看一个复杂物体链在周围挥舞并撞击其他物体,确实能触动许多人的乐趣心理。这使得我们非常想在我们的场景中同时让许多 ragdolls 共存,但很快我们发现,当太多的 ragdolls 处于运动状态或由于迭代求解器需要解决所有这些碰撞而与其他物体发生碰撞时,这会带来巨大的性能损失。因此,让我们探讨一些提高 ragdolls 性能的方法。

减少关节和碰撞体

Unity 在 GameObject | 3D Object | Ragdoll…下提供了一个简单的 ragdoll 生成工具(Ragdoll Wizard)。此工具可以通过选择适当的子 GameObject 来创建 ragdoll,并为任何给定的身体部位或肢体附加JointCollider组件。此工具始终创建 13 个不同的碰撞体和相关的关节(骨盆、胸部、头部、每只手臂两个碰撞体,每条腿三个碰撞体)。

注意,如果左脚或右脚的transform组件引用没有分配,Ragdoll Wizard 不会抱怨,但就像对其他组件一样,如果尝试创建没有分配的网格,Unity 将会抛出NullReferenceException。确保在我们尝试创建 ragdoll 时,所有 13 个transform组件引用都已分配。

然而,仅使用七个碰撞体(骨盆、胸部、头部和每条肢体一个碰撞体)就可以大大减少开销,尽管这会牺牲 ragdoll 的真实感。这可以通过删除不需要的碰撞体并手动重新分配角色关节的connectedBody属性到适当的父关节来实现(将手臂碰撞体连接到胸部,将腿部碰撞体连接到骨盆)。

注意,我们在使用 Ragdoll Wizard 创建 ragdoll 时分配了一个质量值。这个质量值会根据适当的关节分布,因此代表了物体的总质量。我们应该确保我们不会将质量值设置得过高或过低,与其他游戏中的物体相比,以避免潜在的不稳定性。

避免 ragdolls 之间的碰撞

当允许 ragdolls 与其他 ragdolls 碰撞时,它们的性能成本会呈指数增长。实际上,任何关节碰撞都需要求解器计算施加到所有连接到它的关节上的结果速度,然后是连接到另一个 ragdoll 的每个关节。这意味着在实践中,两个 ragdolls 都必须完全解决多次。此外,如果 ragdolls 的各个部分在相同碰撞中可能相互碰撞,这会变得更加复杂。

这对于求解器来说是一个艰巨的任务,因此我们应该避免它。最好的办法是使用碰撞矩阵。明智的做法是将所有 ragdolls 分配到它们自己的层,并在碰撞矩阵中取消选中相应的复选框,这样给定层的对象就不能与同一层的对象发生碰撞。

替换、停用或移除非活动 ragdolls

在某些游戏中,一旦 ragdoll 到达其最终位置,我们就不再需要它作为可交互对象留在游戏世界中。然后,当它们不再需要时,我们可以停用、销毁或用更简单的方法替换 ragdoll(一个很好的技巧是替换为之前建议的只使用七个关节的简单版本)。这种简化通常被用作降低硬件性能较弱/低质量设置的开销的手段,或者作为允许更多 ragdolls 共存于场景中的妥协。如果已经存在一定数量的 ragdolls,这甚至可以动态使用。

我们需要一个对象来跟踪所有的 ragdolls,每当创建一个 ragdoll 时都会得到通知,跟踪当前存在的 ragdolls 数量,通过RigidBody.IsSleeping()监视它们直到它们进入休眠状态,然后对它们进行适当的处理。这个对象还可以选择实例化更简单的 ragdoll 变体,如果场景中已经包含比合理数量更多的 ragdolls。这将是我们利用第二章中探讨的消息系统的好机会。

无论我们选择哪种方法来提高我们的 ragdolls 的表现,无疑都会导致将 ragdolls 作为游戏功能进行限制,无论是通过实例化更少的 ragdolls、减少它们的复杂性,还是缩短它们的存活时间,但考虑到性能提升的机会,这些妥协是合理的。

知道何时使用物理引擎

提高一个功能性能的最明显方法就是尽可能避免使用它。对于我们游戏中的所有可移动对象,我们应该花点时间问问自己,是否真的需要物理引擎的介入。如果不是,我们应该寻找机会用更简单、成本更低的东西来替换它们。

也许我们正在使用物理引擎来检测玩家是否掉入了死亡区域(水、熔岩、死亡悬崖等),但我们的游戏足够简单,我们只在特定高度有死亡区域。在这种情况下,我们可以完全避免使用物理碰撞器,只需检查玩家的y位置是否低于某个特定值即可。

考虑以下示例——我们试图模拟流星雨,我们的第一反应是让许多物体通过物理刚体移动,通过碰撞体检测地面碰撞,然后在碰撞点生成爆炸。然而,也许地面始终是平坦的,或者我们有访问地形高度图进行一些基本的碰撞检测。在这种情况下,可以通过手动在一段时间内缓动物体的 transform.position 来简化物体旅行,以模拟相同的旅行行为,而不需要任何物理组件。在这两种情况下,我们可以通过简化情况并将工作推入脚本代码来减少物理开销。

缓动是一个常见的简写术语,指的是在一段时间内逐渐从一个值插值到另一个值的行为。Unity Asset Store 上有许多有用的(且免费的)缓动库,可以提供很多有用的功能。尽管如此,要注意这些库中可能存在的潜在的低优化。

反过来也是可能的。可能会有这样的情况,我们通过脚本代码执行大量的计算,而这些计算可以通过物理相对简单地处理。例如,我们可能实现了一个包含许多可拾取物体的库存系统。当玩家按下拾取物体键时,这些物体中的每一个都可能被与玩家的位置进行比较,以确定哪个物体是最接近的。

我们可以考虑在按键时用单个 Physics.OverlapSphere() 调用来替换所有的脚本代码,以获取附近的物体,然后从结果中找出最近的拾取物体(或者,更好的是,自动拾取所有物体。为什么让玩家重复点击不必要的次数呢?)。这可以大大减少每次按键时必须比较的总物体数量,尽管应该进行比较以确保这一点。

确保你试图从场景中移除不必要的物理工作,或者使用物理来替换通过脚本代码执行时成本较高的行为。机会就像你的创造力一样广泛和深远。识别这种机会需要经验,但这是在当前和未来的游戏开发项目中节省性能的重要技能。

摘要

我们已经介绍了许多提高游戏物理模拟性能和一致性的方法。在涉及昂贵系统如物理引擎的情况下,最佳技术就是避免使用。我们越少使用系统,就越少担心它产生瓶颈。在最坏的情况下,我们可能需要缩小游戏范围,将物理活动仅限于必要部分,但正如我们所学的,有 plenty of ways to reduce physics complexity without causing any noticeable gameplay effects.

在下一章中,我们将深入 Unity 的渲染管线,了解如何通过利用之前章节中性能提升所释放的所有 CPU 周期,来最大化应用程序的图形保真度。

第六章:动态图形

毫无疑问,现代图形设备的渲染管线非常复杂。即使是将单个三角形渲染到屏幕上,也需要调用大量的图形 API。这包括创建用于相机视图的缓冲区,该缓冲区连接到操作系统(通常通过某种类型的窗口系统),为顶点数据分配缓冲区,设置数据通道以将顶点和纹理数据从 RAM 传输到 VRAM,配置每个内存空间以使用特定的数据格式,确定相机可见的对象,设置并初始化三角形的绘制调用,等待渲染管线完成其任务,最后将渲染的图像呈现到屏幕上。然而,这种看似复杂且过度设计的绘制简单对象的方式有一个简单的理由——渲染通常涉及重复执行相同的任务,而所有这些初始设置使得未来的渲染任务非常快速。

CPU 被设计来处理几乎任何计算场景,但不能同时处理太多任务,而 GPU 被设计用于处理极其大量的并行处理,但它们在处理复杂度方面有限,否则会破坏这种并行性。它们的并行性质需要大量数据被非常快速地复制。在渲染管线的设置过程中,我们为我们的图形数据配置内存数据通道。因此,如果这些通道被正确配置为传递的数据类型,那么它们将更有效地运行。然而,配置不当会导致相反的结果。

在整个图形渲染过程中,CPU 和 GPU 都被使用,这使得它成为跨越软件;硬件;多个内存空间,编程语言(每种语言都适合不同的优化),处理器和处理器类型;以及大量可以添加到混合中的特殊情况的高速度处理和内存管理舞蹈。

更复杂的是,我们将遇到的每个渲染情况都有其独特之处。在两个不同的 GPU 上运行相同的应用程序通常会导致苹果与橙子的比较,因为它们支持不同的功能和 API。

在如此复杂的硬件和软件系统中确定瓶颈所在可能具有挑战性,如果我们想要对现代渲染管线中性能问题的来源有强烈的直接直觉,这可能需要一生在 3D 图形行业的经验工作。

幸运的是,性能分析再次发挥了救星的作用,这使得成为渲染管线大师变得不那么必要。如果我们能够收集到关于每个设备的资料,使用多个性能指标进行比较,并调整我们的场景来观察不同的渲染特性如何影响其行为,那么我们应该有足够的证据来找到问题的根源并做出适当的改变。因此,在本章中,你将学习如何收集正确的数据,深入挖掘渲染管线以找到问题的真正源头,并探索各种解决方案和针对众多潜在问题的应对措施。

当涉及到提高渲染性能时,有许多主题需要讨论。因此,在本章中,我们将探讨以下主题:

  • 对渲染管线的简要探索,重点关注 CPU 和 GPU 介入的部分

  • 确定我们的渲染是否受 CPU 或 GPU 限制的一般技术

  • 一系列性能优化技术和特性,例如:

    • 使用 GPU 实例化

    • 利用细节级别LOD)和其他剔除组

    • 使用遮挡剔除

    • 优化粒子系统

    • 改进 Unity UI

    • 优化你的着色器

    • 使用光照贴图优化光照和阴影

    • 应用移动特定的渲染增强

探索渲染管线

根据设备是受 CPU 活动限制(我们受 CPU 限制)还是受 GPU 活动限制(我们受 GPU 限制),渲染性能不佳可能以多种方式表现出来。调查受 CPU 限制的应用程序可能相对简单,因为所有的 CPU 工作都包括从磁盘/内存加载数据和调用图形 API 指令。

然而,受 GPU 限制的应用程序可能更难分析,因为根本原因可能来自渲染管线中的大量潜在位置之一。我们可能会发现,我们需要依赖一点猜测或排除法来确定 GPU 瓶颈的来源。在任何情况下,一旦问题被发现并解决,我们都可以期待显著的改进,因为小的修复在解决渲染管线中的问题时往往会带来巨大的回报。

在第三章《批处理的好处》中,我们简要介绍了渲染管线。为了简要总结关键点,我们知道 CPU 通过图形 API 发送渲染指令,通过硬件驱动程序流向 GPU 设备,这导致一系列渲染指令积累在一个称为命令缓冲区的队列中。GPU 逐个处理这些命令,直到命令缓冲区为空。只要 GPU 能够跟上下一帧开始前的指令速率和复杂性,我们就能保持帧率。然而,如果 GPU 落后,或者 CPU 花费太多时间生成命令,帧率就会开始下降。

以下是一个现代 GPU 上典型渲染管线的高度简化图(这也可以根据设备、技术支持和自定义优化而有所不同),展示了发生的步骤的广泛视图:

图片

最上面一行表示在 CPU 中进行的操作,这包括通过硬件驱动程序调用图形 API 以及将命令推送到 GPU。接下来的两行表示在 GPU 中进行的步骤。由于 GPU 的复杂性,其内部过程通常分为两个不同的部分——前端后端,这需要一些额外的解释。

GPU 前端

前端指的是渲染过程中 GPU 处理顶点数据的部分。让我们了解它是如何工作的:

  1. 前端将从 CPU 接收网格数据(一个包含大量顶点信息的大包),并发出一个绘制调用。

  2. GPU 随后从网格数据中收集所有顶点信息,并通过顶点着色器传递它们,顶点着色器可以修改它们并以1-to-1的方式输出。

  3. 因此,GPU 现在有一个要处理的原始数据列表(三角形——3D 图形中最原始的形状)。

  4. 接下来,光栅化器将这些原始数据转换为最终图像需要绘制的像素,基于其顶点的位置和当前相机视图创建原始数据。从这个过程中生成的像素列表被称为片段,将在后端进行处理。

顶点着色器是类似于 C 语言的程序,它们确定它们感兴趣的数据输入以及它们将如何操作这些数据,然后输出一组信息供光栅化器生成片段。它也是细分过程的家,这个过程由几何着色器(有时称为细分着色器)处理,类似于顶点着色器,因为它们是上传到 GPU 的小脚本,但它们被允许以1-to-many的方式输出顶点,因此可以程序化地生成额外的几何形状。

着色器这个术语是从这些脚本最初主要处理光照和着色任务时的一个时代遗留下来的,那时它们的作用还没有扩展到包括今天它们所使用的所有任务。

GPU 后端

后端代表渲染管线中处理片段的部分。让我们看看它是如何工作的:

  1. 每个片段都将通过一个片段着色器(也称为像素着色器)。与顶点着色器相比,这些着色器通常涉及更多的复杂活动,例如深度测试、alpha 测试、着色、纹理采样、光照、阴影以及各种后期处理效果,仅举几例。

  2. 然后,这些数据会被绘制到帧缓冲区上,帧缓冲区保存了当前图像,一旦完成当前帧的渲染任务,最终会发送到显示设备(我们的显示器)上。图形 API 默认情况下通常使用两个帧缓冲区(尽管在自定义渲染场景中可以生成更多)。

  3. 在任何给定时刻,一个帧缓冲区包含我们渲染到帧的数据,并且正在被展示到屏幕上,而另一个帧缓冲区则被 GPU 积极绘制,同时它完成命令缓冲区中的命令。

  4. 当 GPU 达到swap buffers命令(CPU 要求 GPU 完成的给定帧的最后一个指令)时,帧缓冲区就会翻转,以便展示新的帧。

  5. 然后,GPU 将使用旧的帧缓冲区来绘制下一个帧。

  6. 每当渲染一个新的帧时,这个过程就会重复;因此,GPU 只需要两个帧缓冲区来处理这个任务。

从调用图形 API 到交换帧缓冲区,这个过程会持续不断地为每个网格、顶点、片段和帧重复,只要我们的应用程序仍在渲染。

两个指标往往会导致后端出现瓶颈——填充率和内存带宽。让我们来稍微了解一下它们。

填充率

填充率是一个包含性术语,指的是 GPU 绘制片段的速度。然而,这仅包括在给定的片段着色器中启用了所有各种条件测试后幸存下来的片段。片段只是一个潜在像素,如果它未能通过任何启用的测试,那么它就会被立即丢弃。这可以是一个巨大的性能提升,因为渲染管线可以跳过昂贵的绘制步骤,转而开始处理下一个片段。

一个可能剔除片段的测试示例是Z 测试,它检查来自较近对象的片段是否已经绘制到相同的片段位置(Z指的是从摄像机的视角来看的深度维度)。如果是这样,当前片段就会被丢弃。如果不是,那么片段就会通过片段着色器并绘制到目标像素上,这正好消耗了我们的填充率中的一个。现在,想象一下将这个过程乘以成千上万的重叠对象,每个对象都生成数百或数千个可能的片段(更高的屏幕分辨率需要处理更多的片段)。由于主摄像机的所有可能重叠,这可能导致每帧处理数百万个片段。此外,我们每秒试图重复这个过程数十次。这就是为什么在渲染管线中进行如此多的初始设置很重要,而且应该很明显,跳过尽可能多的这些绘制操作可以显著节省渲染成本。

图形卡制造商通常将特定的填充率作为卡的特性进行宣传,通常以每秒千兆像素的形式出现,但这有点误导,因为它更准确地应该被称为每秒千兆片段;然而,这个论点主要是学术性的。无论如何,较大的值告诉我们设备可以潜在地通过渲染管线推送更多的片段。因此,在每秒 30 千兆像素的预算和 60Hz 的目标帧率下,我们可以在填充率瓶颈之前处理30,000,000,000/60 = 5 亿个片段每帧。以 2,560 x 1,440 的分辨率和最佳情况,即每个像素只绘制一次,理论上我们可以绘制整个场景大约 125 次而不会出现任何明显的问题。

可惜,这不是一个完美的世界。填充率也被其他高级渲染技术消耗,例如阴影和后期处理效果,这些技术需要处理相同的片段数据并在帧缓冲区上执行它们的遍历。即便如此,由于对象渲染的顺序,我们最终总是会因为相同的像素而有一些重绘。这被称为过度绘制,它是衡量我们如何有效地利用填充率的一个有用的指标。

过度绘制

我们有多少过度绘制可以通过将所有对象以加法 alpha 混合和平滑着色渲染来直观表示。过度绘制高的区域会显得更亮,因为相同的像素通过加法混合多次绘制。这正是场景窗口的过度绘制着色模式揭示我们的场景正在经历多少过度绘制的方式。

以下截图显示了正常绘制(左)与场景窗口的过度绘制着色模式(右)的几个千个盒子场景:

图片

我们拥有的过度绘制越多,我们就浪费了更多的填充率,因为我们需要覆盖片段数据。我们可以应用几种技术来减少过度绘制,我们将在稍后探讨。

注意,用于渲染的队列有几种不同类型,可以分为两种:不透明队列透明队列

在不透明队列中渲染的对象可以通过Z-测试剔除片段,正如之前所解释的。然而,在透明队列中渲染的对象不能这样做,因为它们的透明性质意味着我们无法假设无论有多少其他对象挡在前面,它们都不需要被绘制,这导致了很多不必要的绘制。

所有 Unity UI 对象总是以透明队列渲染,这使得它们成为过度绘制的一个重要来源。

内存带宽

后端瓶颈的另一个潜在来源是内存带宽。每当需要从 GPU 的 VRAM 的一部分将纹理拉到较低的内存级别时,就会消耗内存带宽。这通常发生在纹理采样时,片段着色器试图为给定位置上的给定片段选择匹配的纹理像素(或texel)。GPU 包含多个核心,每个核心都可以访问相同的 VRAM 区域,但它们也各自包含一个更小、本地的纹理缓存,存储 GPU 最近最频繁使用的纹理。这与 CPU 内存缓存级别的多样性设计相似,允许内存在链中上下传输。这是硬件设计的一个解决方案,因为更快的内存不可避免地会更容易生产且成本更高。因此,我们不是有一个巨大的、昂贵的 VRAM 块,而是一个大型的、廉价的 VRAM 块,但使用一个更小、非常快速的底层纹理缓存来进行采样,这样我们就得到了两者的最佳结合;也就是说,快速采样且成本较低。

如果需要的纹理已经存在于核心的本地纹理缓存中,那么采样通常变得非常快,几乎感觉不到。如果不是这样,那么纹理必须从 VRAM 中拉入,才能进行采样。这实际上是对纹理缓存的一个缓存未命中,因为它现在需要时间来查找和从 VRAM 中拉取所需的纹理。这种传输消耗了我们可用内存带宽的一部分,具体来说,是存储在 VRAM 中纹理文件的总大小(这可能不是原始文件的确切大小或 RAM 中的大小,因为 GPU 级别的压缩技术)。

如果我们在内存带宽上遇到瓶颈,GPU 将不断获取必要的纹理文件,但整个过程将被限制,因为纹理缓存需要等待数据出现才能处理给定的片段批次。GPU 无法及时将数据推回帧缓冲区以在屏幕上渲染,这会阻塞整个过程,并导致帧率低下。

正确使用内存带宽是另一个预算关注点。例如,每核心每秒 96 GB 的内存带宽和每秒 60 帧的目标帧率,GPU 可以承受在内存带宽瓶颈之前,每帧大约提取 1.6 GB(96/60)的纹理数据。当然,这不是一个精确的预算,因为存在缓存未命中,但它确实为我们提供了一个大致的数值来工作。

内存带宽通常按每核心列出,但一些 GPU 制造商可能会通过将内存带宽乘以核心数来误导,列出更大的但不太实用的数字。因此,可能需要进行研究,以便比较苹果与苹果。

注意,这个值并不是我们游戏在项目中可以包含的纹理数据量的最大限制,也不是 CPU RAM,甚至不是 VRAM。这是一个本质上限制一帧内可以发生多少纹理交换的指标。同一个纹理可以在一帧内被多次拉回和推去,这取决于需要使用纹理的着色器的数量、对象的渲染顺序以及纹理采样必须发生的频率。只有少数对象可以消耗整个 GB 的内存带宽,因为可用的纹理缓存空间是有限的。如果一个着色器需要大量的大纹理,则更有可能造成缓存未命中,从而在内存带宽上造成瓶颈。如果我们考虑多个需要不同高质量纹理的对象以及多个二级纹理贴图(法线贴图、发射贴图等),并且这些贴图没有批量处理在一起,那么这种情况会令人惊讶地容易触发。在这种情况下,纹理缓存将无法长时间保留单个纹理文件,以便在下一个渲染过程中立即采样它。

我们现在已经涵盖了 GPU 的前端和后端,接下来我们将继续探索我们的渲染管线下一部分:光照和阴影。

光照和阴影

在现代游戏中,单个对象很少在单个步骤中完全完成渲染,这主要是由于光照和阴影。这些任务通常在多个片段着色器的多个“遍”中处理,每个遍对应于几个光源之一,最终结果被组合起来,以便多个光源都有机会被应用。结果看起来更加逼真,或者至少更具有视觉吸引力。

需要多次遍历来收集阴影信息。因此,让我们开始:

  1. 我们首先设置场景,使其包含产生阴影的对象和接收阴影的对象,分别负责产生和接收阴影。

  2. 然后,每次渲染阴影接收者时,GPU 都会从光源的视角渲染任何阴影产生者对象到一个纹理中,以收集每个片段的距离信息。

  3. 然后,它会对阴影接收者执行相同的操作,但现在它知道阴影产生者会从光源重叠哪些片段,因此它可以渲染这些片段为较暗,因为它们将位于光源施加在阴影产生者上产生的阴影中。

  4. 此信息随后成为被称为阴影贴图的附加纹理,并在从主摄像机的视角渲染时与阴影接收者的表面混合。这会使其表面在某些位置看起来更暗,因为这些位置有其他物体位于光源和指定物体之间。

创建光照贴图的过程与此类似,光照贴图是为场景中更静态的部分预先生成的光照信息。

光照和阴影在整个渲染管线中往往消耗大量资源。我们需要每个顶点提供一个法线方向(一个指向表面的向量)以确定光照应该如何从该表面反射,并且我们可能还需要额外的顶点颜色属性来应用一些额外的着色。这为 CPU 和前端提供了更多信息以传递。由于需要多次遍历片段着色器才能完成最终的渲染,后端在填充率(需要绘制、重绘和合并的大量像素)和内存带宽(为光照贴图和阴影贴图拉入或拉出的额外纹理)方面都保持忙碌。这就是为什么实时阴影与其他大多数渲染功能相比异常昂贵,并且当启用时,会大幅增加绘制调用次数。

然而,光照和阴影可能是游戏艺术和设计中最重要的两个部分,需要正确处理,通常使得额外的性能要求值得成本。良好的光照和阴影可以将一个平凡的场景转变为壮观的事物,因为专业的着色有一种神奇的魅力,使其视觉上吸引人。即使是低多边形艺术风格(例如,移动游戏《纪念碑谷*》)也严重依赖于良好的光照和阴影配置文件,以便玩家能够区分不同的对象并创建一个视觉上令人愉悦的场景。

Unity 提供了多个影响光照和阴影的功能,从实时光照和阴影(每种类型都有多种)到静态光照,称为光照贴图。有很多选项可以探索,当然,如果不小心,会有很多可能导致性能问题。

Unity 文档详细介绍了所有各种光照功能。从以下页面开始,并逐步学习。这样做将非常值得花费时间,因为这些系统影响整个渲染管线。参考以下内容:

有两种不同的渲染格式,它们可以极大地影响我们的光照性能,称为前向渲染延迟渲染。这些渲染选项的设置可以在“编辑 | 项目设置 | 玩家 | 其他设置 | 渲染”下找到,并且可以在每个平台上进行配置。

前向渲染

前向渲染是我们场景中渲染灯光的传统形式,如前所述。在前向渲染过程中,每个对象将通过相同的着色器进行多次渲染。所需的遍历次数将基于光源的数量、距离和亮度。Unity 将尝试优先渲染对对象影响最大的DirectionalLight组件,并以一个基础遍历作为起点渲染对象。然后,它将取附近的一些最强大的PointLight组件,并通过相同的片段着色器多次重新渲染同一个对象。每个光源点将基于顶点进行处理,所有剩余的光源将使用称为球谐函数的技术合并成一个平均颜色。

通过将灯光的渲染模式设置为例如“不重要”等值,并更改“编辑 | 项目设置 | 质量 | 像素光照数量”的值,可以简化这种行为。此值限制了用于前向渲染的光照数量,但任何设置为“重要”渲染模式的光照将覆盖此限制。因此,我们必须负责任地使用这些设置的组合。

如我们可能想象的那样,在存在大量光源的场景中使用前向渲染可能会迅速增加我们的绘制调用次数,这是由于配置的渲染状态和所需的着色器遍历数量。

关于前向渲染的更多信息可以在 Unity 文档中找到,链接为docs.unity3d.com/Manual/RenderTech-ForwardRendering.html

延迟着色

延迟渲染,或称为延迟着色(Deferred Shading),是一种大约十年前就在 GPU 上可用的技术,但由于涉及的限制和移动设备上支持有限,它并没有完全取代前向渲染方法。

延迟着色(Deferred Shading)之所以被称为“延迟着色”,是因为实际的着色过程直到处理过程的后期才会发生,也就是说,它被延迟到后期。它通过创建一个几何缓冲区(称为G-Buffer),在没有任何光照应用的情况下首先渲染场景。有了这些信息,延迟着色系统可以在单次遍历中生成一个光照配置文件。

从性能角度来看,结果相当令人印象深刻,因为它可以在很少的绘制调用努力下生成非常好的每像素光照。一个缺点是,如抗锯齿、透明度和将阴影应用于动画角色等效果不能仅通过延迟着色来管理。在这种情况下,将前向渲染技术作为后备方案应用,以覆盖这些任务,因此需要额外的绘制调用来完成。延迟着色的一个更大问题是,它通常需要更强大、更昂贵的硬件,并且并非所有平台都可用,因此较少的用户能够使用它。

Unity 文档包含了关于延迟着色技术及其优点和缺点的优秀信息来源,这些信息可以在docs.unity3d.com/Manual/RenderTech-DeferredShading.html找到。

顶点光照着色(遗留)

从技术上讲,除了两种光照方法之外,还有其他两种。剩下的两种是顶点光照着色和非常原始、功能宽松的延迟渲染版本(在 Unity 文档中,这被称为遗留延迟光照渲染路径)。顶点光照着色是对光照的极大简化,因为光照只考虑每个顶点而不是每个像素。换句话说,整个面基于入射光颜色进行着色,而不是通过单个像素混合光照颜色。

并不期望许多,或者实际上任何,3D 游戏会使用这种遗留技术,因为缺乏阴影和适当的光照使得深度可视化非常困难。它主要被简单的 2D 游戏使用,这些游戏不需要使用阴影、法线贴图以及各种其他光照功能。

全局照明

全局照明(GI)是烘焙光照贴图的一种实现。光照贴图与阴影技术创建的阴影贴图类似,为每个代表额外光照信息的对象生成一个或多个纹理,这些纹理随后在片段着色器的光照过程中应用于对象,以模拟静态光照效果。

这些光照贴图与其他形式的光照之间的主要区别在于,光照贴图是在编辑器中预先生成(或烘焙)并打包到游戏构建中的。这确保了我们不需要在运行时不断重新生成这些信息,从而节省了大量的绘制调用和显著的 GPU 活动。由于我们可以烘焙这些数据,我们有时间生成非常高质量的光照贴图(当然,这要以我们需要处理的更大的生成纹理文件为代价)。

由于这些信息是在游戏开始前烘焙的,它们不能在游戏过程中对实时活动做出反应,因此,默认情况下,任何光照贴图信息都只会应用于在光照贴图生成时场景中存在的静态对象,以及它们放置的确切位置。然而,可以向场景中添加光照探针以生成一组额外的光照贴图纹理,这些纹理可以应用于附近移动的动态对象,使这些对象能够从预先生成光照中受益。这不会达到像素级的精度,并且会消耗额外的光照探针地图的磁盘空间,以及在运行时交换这些地图所需的内存带宽,但它确实生成了一个更可信且令人愉悦的光照配置文件。

在过去的几年中,已经开发出几种生成光照贴图的技术,Unity 自从最初发布以来已经使用了几种不同的解决方案。全局照明仅仅是光照贴图背后数学技术的最新一代,它通过计算不仅影响给定对象的光照,还影响光线如何从附近的表面反射,从而提供非常逼真的着色效果。这种效果是通过一个名为 enlighten 的内部系统计算的。这个工具既用于创建静态光照贴图,也用于创建称为 预计算实时全局照明 的东西,它是实时和静态着色的混合,允许我们模拟诸如 一天中的时间(随着时间的推移,太阳光的方向发生变化)等效果,而不依赖于昂贵的实时光照效果。

生成光照贴图时常见的典型问题是生成它们所需的时间长度以及获取当前设置视觉反馈的时间,因为光照映射器通常试图在一次遍历中生成全细节光照贴图。如果用户尝试修改其配置,则整个工作必须取消并重新开始。为了解决这个问题,Unity Technologies 实现了渐进式光照映射器,它在一段时间内更渐进地执行光照映射任务,同时允许在计算过程中对其进行修改。这使得场景中的光照贴图看起来在后台工作时会逐渐变得更加详细,同时允许我们在它仍在工作时更改某些属性,而无需重新启动整个工作。这提供了几乎即时的反馈,并极大地改善了生成光照贴图的流程。

多线程渲染

多线程渲染在大多数系统上默认启用,例如提供多个核心的桌面和游戏机平台。其他平台仍然支持许多低端设备,默认启用此功能,因此它们是一个可切换的选项。对于 Android,可以通过在“编辑 | 项目设置 | 播放器 | 其他设置 | 多线程渲染”下的复选框中启用,而对于 iOS,可以通过在“编辑 | 项目设置 | 播放器 | 其他设置 | 图形 API”下配置应用程序以使用 Apple 的 Metal API 来启用多线程渲染。在撰写本书时,WebGL 不支持多线程渲染。

对于场景中的每个对象,我们需要完成三个任务:确定对象是否需要渲染(通过一种称为视锥剔除的技术),如果是的话,生成渲染对象的命令(因为渲染单个对象可能会产生数十个不同的命令),然后使用相关的图形 API 将命令发送到 GPU。在没有多线程渲染的情况下,所有这些任务都必须在 CPU 的主线程上执行;因此,主线程上的任何活动都成为所有渲染的关键路径。当启用多线程渲染时,将命令推送到 GPU 的任务由渲染线程处理,而其他任务,如剔除和生成命令,则分散到多个工作线程。这种设置可以为 CPU 的主线程节省大量的 CPU 周期,而主线程是大多数其他 CPU 任务发生的地方,例如物理和脚本代码。

启用此功能将影响 CPU 绑定的含义。在没有多线程渲染的情况下,主线程正在执行生成命令缓冲区指令所需的所有工作,这意味着我们可以在其他地方节省的任何性能都将为 CPU 生成命令腾出更多时间。然而,当进行多线程渲染时,大量工作量被推送到单独的线程,这意味着对主线程的改进对通过 CPU 的渲染性能的影响将较小。

注意,无论是否进行多线程渲染,GPU 绑定都是相同的。GPU 始终以多线程的方式执行其任务。

低级渲染 API

Unity 通过他们的CommandBuffer类向我们公开了渲染 API。这允许我们通过发出高级渲染命令,如渲染此对象使用此材质使用此着色器绘制此程序几何体的 N 个实例,直接通过我们的 C#代码控制渲染管线。这种定制功能不如直接访问图形 API 强大,但对于 Unity 开发者来说,这是朝着定制独特图形效果的正确方向迈出的一步。

查看 Unity 文档中的CommandBuffer,以使用此功能,请访问docs.unity3d.com/ScriptReference/Rendering.CommandBuffer.html

如果需要更直接的渲染控制级别,例如我们希望直接调用 OpenGL、DirectX 和 Metal 的图形 API,那么请注意,可以创建一个本地插件(一个用 C++ 编写的库,专门为目标的平台架构编译),该插件可以挂钩到 Unity 的渲染管线,为特定的渲染事件设置回调,类似于 MonoBehaviours 如何挂钩到主 Unity 引擎的各种回调。这绝对是一个对大多数 Unity 用户来说的高级话题,但对于我们渲染技术和图形 API 知识的成熟来说是有用的。

Unity 在docs.unity3d.com/Manual/NativePluginInterface.html提供了关于在本地插件中生成渲染界面的良好文档。

显然,由于涉及到的复杂过程数量众多,GPU 可能以多种不同的方式成为瓶颈。现在我们已经彻底了解了渲染管线和瓶颈可能发生的方式,让我们来探讨如何检测这些问题。

检测性能问题

当你开始查看游戏中的问题时,照明往往被忽视。这是一个新手错误。在接下来的几节中,我们将看到如何检测和解决与照明相关的性能问题。

分析渲染问题

分析器可以用来快速缩小在渲染管线中使用的那两个设备中哪一个是我们瓶颈所在——是 CPU 还是 GPU。我们必须使用分析器窗口的 CPU 使用率和 GPU 使用率区域来检查问题,因为这可以告诉我们哪个设备工作最努力。

以下截图显示了针对 CPU 密集型应用程序的分析器数据。测试涉及创建成千上万的简单立方体对象,没有批处理或阴影技术。这导致 CPU 需要生成命令的绘制调用次数非常大(大约 32,000 次),但由于渲染的对象非常简单,GPU 的工作量相对较少:

图片

本例表明,CPU 的渲染任务消耗了大量的周期(每帧约 25 毫秒),而 GPU 的处理时间不到 4 毫秒,这表明瓶颈位于 CPU。请注意,这个分析测试是在独立应用程序上进行的,而不是在编辑器内。我们现在知道我们的渲染是 CPU 密集型的,可以开始应用一些节省 CPU 的性能改进(注意不要通过这种方式在其他地方引入渲染瓶颈)。

同时,通过 Profiler 对 GPU 瓶颈应用进行性能分析要复杂一些。这次测试涉及创建一个需要最少绘制调用的简单对象,但使用一个非常昂贵的着色器,该着色器对纹理进行数千次采样,在后台创建大量活动。

为了进行公平的 GPU 瓶颈性能分析测试,你应该确保通过“编辑 | 项目设置 | 质量 | 其他 | V 同步计数”禁用垂直同步;否则,很可能会污染我们的数据。

以下截图显示了在独立应用程序中运行此测试时的 Profiler 数据:

图片

如前一个截图所示,CPU 使用区域的渲染任务与 GPU 使用区域的总渲染成本非常接近。我们还可以看到,图像底部的 CPU 和 GPU 时间成本相对相似(大约各 29 毫秒)。这有些令人困惑,因为我们似乎在这两个设备上都有瓶颈,而我们预期 GPU 的工作量应该远大于 CPU。

实际上,如果我们使用分层模式深入到 CPU 使用区域的分析视图,我们会注意到大部分 CPU 时间都花在了标记为 Gfx.WaitForPresent 的任务上。这是 CPU 在等待 GPU 完成当前帧时浪费的时间。因此,尽管看起来我们似乎同时受到 CPU 和 GPU 的限制,但实际上我们是受 GPU 瓶颈所限。即使启用了多线程渲染,CPU 也必须等待渲染管线完成才能开始下一帧。

Gfx.WaitForPresent 也用于表示 CPU 正在等待垂直同步完成,因此需要禁用它进行此测试。

强制测试

如果我们在仔细查看性能分析数据,但仍不确定问题的根源在哪里,或者我们遇到 GPU 瓶颈并需要确定在渲染管线中的瓶颈位置,我们应该尝试强制测试方法,即从场景中删除特定的活动并检查它是否会导致性能显著提升。如果小的改动能带来显著的速度提升,那么我们就有了关于瓶颈位置的强烈线索。如果我们消除了足够的未知变量以确保数据引导我们走向正确的方向,那么这种方法并没有什么害处。

对于 CPU 瓶颈的明显强制测试将是减少绘制调用以检查性能是否突然提升。然而,这通常是不可能的,因为我们可能已经通过静态批处理、动态批处理和纹理图集等技术将绘制调用减少到最小。这意味着我们进一步减少它们的范围非常有限。

然而,我们可以故意增加绘制调用次数的小幅度,要么通过引入更多对象,要么禁用节省绘制调用的功能,如静态和动态批处理,并观察情况是否比之前显著恶化。如果是这样,那么我们有证据表明我们可能非常接近 CPU 限制,或者已经达到这种状态。

对于受 GPU 限制的应用程序,我们可以应用两种有效的暴力测试来确定我们是否受限于填充率或内存带宽:分别降低屏幕分辨率或降低纹理分辨率。

通过降低屏幕分辨率,我们将要求光栅化器生成显著更少的片段,并将它们传输到更小的像素画布上供后端处理。这将减少应用程序的填充率消耗,为渲染管线这个关键部分提供额外的空间。因此,如果性能随着屏幕分辨率的降低而突然提升,那么填充率应该是我们首要关注的问题。

将分辨率从 2560 x 1440 降低到 800 x 600,改善因子约为八倍,这通常足以足够减少填充率成本,使应用程序再次表现良好。

同样,如果我们受限于内存带宽,那么降低纹理质量可能会带来显著的性能提升。通过这样做,我们缩小了纹理的大小,大大减少了片段着色器的内存带宽成本,使得 GPU 能够更快地获取必要的纹理。通过访问“编辑 | 项目设置 | 质量 | 纹理质量”,并将值设置为“半分辨率”、“四分之一分辨率”或“八分之一分辨率”,可以全局降低纹理质量。

受 CPU 限制的应用程序,通过本书中几乎每一项性能提升技巧,都有充足的机会进行性能优化。如果我们从其他活动中释放 CPU 周期,那么我们可以通过更多的绘制调用渲染更多对象,当然,这也意味着每个对象都会让 GPU 承担更多的活动。然而,当我们试图改进渲染管线其他部分时,还有额外的机会在绘制调用次数上做一些间接的改进。这包括遮挡剔除、调整我们的光照和阴影行为,以及修改我们的着色器。这些内容将在以下章节中解释,当我们研究各种性能提升方法时。

同时,我们可能需要应用一些暴力测试和猜测来确定一个 GPU 受限的应用程序瓶颈在哪里。大多数应用程序的瓶颈在于填充率或内存带宽,因此我们应该从这里开始。在桌面应用程序中,前端很少出现性能瓶颈,所以只有在确认其他来源不是问题之后才值得检查。与片段着色器相比,顶点着色器通常很简单,因此前端处理可能引起问题的唯一机会是推入过多的几何形状或者拥有过于复杂的几何着色器。

最终,这项调查应该帮助我们确定我们是 CPU 受限还是 GPU 受限,在后一种情况下,我们是在前端受限还是后端受限,再次在后一种情况下,我们是在填充率受限还是内存带宽受限。有了这些知识,我们可以应用几种技术来提高性能。

渲染性能提升

现在我们应该拥有所有必要的信息来理解性能瓶颈,以便我们可以开始应用修复。在本章的剩余部分,我们将介绍一系列技术,以提高 CPU 受限和 GPU 受限应用程序的渲染管线性能。

启用/禁用 GPU 皮肤

第一条建议涉及一个设置,它以牺牲另一方的代价减轻 CPU 或 GPU 前端的工作负担,即 GPU 皮肤。皮肤是这样一个过程,即根据动画骨骼的当前位置转换网格顶点。动画系统在 CPU 上工作,转换用于确定对象当前姿态的骨骼,但动画过程中的下一个重要步骤是将网格顶点围绕这些骨骼包裹,以放置网格在最终姿态。这是通过遍历每个顶点并对其连接的骨骼执行加权平均来实现的。

这个顶点处理任务可以发生在 CPU 上,也可以发生在 GPU 的前端,这取决于是否启用了 GPU 皮肤选项。此功能可以在“编辑 | 项目设置 | 玩家设置 | 其他设置 | GPU 皮肤”下切换。启用此选项将皮肤活动推送到 GPU,但请注意,CPU 仍然需要将数据传输到 GPU,并为任务在命令缓冲区生成指令,因此它并没有完全消除 CPU 的工作量。禁用此选项通过在传输网格数据之前让 CPU 解决网格的姿态来减轻 GPU 的负担,并简单地要求 GPU 按原样绘制。显然,如果我们场景中有许多动画网格,这个功能很有用,并且可以帮助将工作推送到最不繁忙的设备。

减少几何复杂性

这个技巧关注的是 GPU 前端。我们已经在第四章“优化您的艺术资产”中介绍了一些网格优化技术,这些技术可以帮助减少我们的网格顶点属性。作为一个快速提醒,使用包含大量不必要的 UV 和法线向量数据的网格并不罕见,因此我们应该检查我们的网格是否存在这种多余的冗余。我们还应该让 Unity 为我们优化结构,这可以最小化缓存未命中,因为顶点数据是在前端读取的。

目标仅仅是减少实际的顶点数。这里有三种解决方案:

  • 首先,我们可以通过艺术团队手动调整并生成具有较低多边形计数的网格,或者使用网格简化工具来简化网格。

  • 第二,我们可以简单地从场景中删除网格,但这应该是最后的手段。

  • 第三种方案是通过实现自动剔除,例如使用 LOD 等功能,这些将在本章后面解释。

减少细分

通过几何着色器进行细分可以非常有趣,因为它是一种相对较少使用的技巧,可以使我们的图形效果在仅使用最常见效果的众多游戏中脱颖而出。然而,它可能会极大地增加前端处理的工作量。

除了改进我们的细分算法或减轻其他前端任务带来的负担,以便我们的细分任务有更多的空间呼吸之外,我们实际上没有可以利用的简单技巧来提高细分。无论如何,如果我们前端存在瓶颈并正在使用细分技术,我们应该检查它们是否没有消耗前端预算的大部分。

使用 GPU 实例化

GPU 实例化是一种通过利用它们将具有相同的渲染状态这一事实来快速渲染相同网格多个副本的方法,因此需要最少的绘制调用。这实际上与动态批处理非常相似,但不是自动过程。实际上,我们可以将动态批处理视为“穷人的 GPU 实例化”,因为 GPU 实例化可以带来更好的节省,并允许通过允许参数化变化来实现更多定制。

GPU 实例化在材质级别通过勾选“启用实例化”复选框应用,可以通过修改着色器代码来引入变化。这样,我们可以为不同的实例提供不同的旋转、缩放、颜色等。这对于渲染森林和岩石区域等场景非常有用,在这些场景中,我们希望渲染数百或数千个略有变化的网格副本。

注意,由于与它们不能动态批处理类似的原因,骨骼网格渲染器不能实例化,并且并非所有平台和 API 都支持 GPU 实例化。

以下截图展示了 GPU 实例化在一组 512 个立方体对象(应用了一些额外的光照和阴影以增加总绘制调用次数)上的好处:

图片

与动态批处理相比,此系统具有更高的灵活性,因为我们能更好地控制对象是如何一起批处理的。当然,如果我们以低效的方式批处理事物,会有更多的错误机会,因此我们应该谨慎地明智使用它们。

查阅 Unity 文档以获取更多关于 GPU 实例化的信息,请访问docs.unity3d.com/Manual/GPUInstancing.html

使用基于网格的 LOD

LOD 是一个广泛的概念,指的是根据对象与摄像机的距离以及/或它们在摄像机视图中占据的空间大小动态替换特征。由于在远处很难区分低质量和高质量对象,因此几乎没有必要渲染高质量版本,所以我们不妨用更简化的东西动态替换远处的对象。LOD 最常见实现是基于网格的 LOD,其中网格随着摄像机越来越远而动态替换为更低的细节版本。

通过在场景中放置多个对象并将它们设置为具有附加LODGroup组件的GameObject的子对象,可以实现基于网格的 LOD。LOD 组的作用是从这些对象生成边界框,并根据边界框在摄像机视野中的大小决定渲染哪个对象。如果对象的边界框占据了当前视图的大部分区域,则它将启用分配给较低 LOD 组的网格(们),如果边界框非常小,则用来自较高 LOD 组的网格(们)替换网格(们)。如果网格太远,可以配置为隐藏所有子对象。因此,通过适当的设置,我们可以让 Unity 用更简单的替代品替换网格,或者完全剔除它们,从而减轻渲染过程的负担。

查阅 Unity 文档以获取更多关于基于网格的 LOD 功能的详细信息,请访问docs.unity3d.com/Manual/LevelOfDetail.html

完全实现此功能可能需要我们投入大量的开发时间;艺术家必须生成相同对象的低多边形版本,关卡设计师必须生成 LOD 组,配置它们,并测试它们以确保在摄像机靠近或远离时不会引起令人不快的过渡。

注意,一些游戏开发中间件公司提供用于自动 LOD 网格生成的第三方工具。这些工具可能值得调查,以比较它们的使用便捷性、质量损失和成本效益。

基于网格的 LOD 也会在磁盘占用、RAM 和 CPU 方面给我们带来成本;替代网格需要被打包、加载到 RAM 中,并且LODGroup组件必须定期检查相机是否移动到了需要改变 LOD 级别的新位置。然而,在渲染管线上的好处是非常令人印象深刻的。动态渲染更简单的网格可以减少我们需要传递的顶点数据量,并可能减少绘制调用次数、填充率和渲染对象所需的内存带宽。

由于基于网格的 LOD(细节层次)功能需要大量的牺牲,开发者应该避免通过自动假设基于网格的 LOD 会帮助他们来预先优化。过度使用该功能将导致应用程序性能的其他部分负担加重,消耗宝贵的发展时间,这一切都是为了不必要的担忧。只有在开始观察到渲染管线中的问题时,并且我们有足够的 CPU、RAM 和开发时间时,才应该使用它。

话虽如此,具有广阔世界视野和大量相机移动的场景可能需要尽早实施这项技术,因为增加的距离和大量可见对象可能会极大地增加顶点计数。作为反例,始终在室内或具有向下看世界的视点的相机场景将发现这项技术几乎没有好处,因为对象通常会始终以相似的距离从相机移动。例如包括实时策略RTS)和多人在线战斗竞技场MOBA)游戏。

遮挡剔除组

遮挡剔除组是 Unity API 的一部分,它有效地允许我们创建自己的自定义 LOD 系统,作为动态替换某些游戏或渲染行为的方式。我们可能想要应用 LOD 的一些例子包括用骨骼较少的版本替换动画角色、应用更简单的着色器、在远距离跳过粒子系统生成,以及简化 AI 行为。

由于遮挡剔除组系统在最基本层面上只是告诉我们对象是否对相机可见以及它们有多大,它还在游戏领域有其他用途,例如确定某些敌人出生点是否当前对玩家可见,或者玩家是否正在接近某些区域。遮挡剔除组系统提供了广泛的可能性,使其值得考虑。当然,实现、测试和重新设计场景以利用这些功能所花费的时间可能是相当大的。

查阅 Unity 文档以获取有关遮挡剔除组的更多信息,请访问docs.unity3d.com/Manual/CullingGroupAPI.html

利用遮挡剔除

减少填充率消耗和过度绘制最有效的方法之一是利用 Unity 的遮挡剔除系统。该系统通过将世界划分为一系列小单元,并通过场景中的虚拟相机进行飞行,根据现有对象的大小和位置记录哪些单元对其他单元不可见(被遮挡)来工作。

注意,这与视锥剔除技术不同,视锥剔除技术剔除当前相机视图外的对象。视锥剔除始终处于激活和自动状态。因此,通过此过程剔除的对象将自动被遮挡剔除系统忽略。

遮挡剔除数据只能为在“静态标志”下拉菜单下正确标记为“遮挡器静态”和/或“被遮挡静态”的对象生成。遮挡器静态是我们期望的静态对象的一般设置,这些对象可能非常大,以至于它们将同时遮挡其他对象并被其他对象遮挡,例如摩天大楼或山脉,这些可以隐藏它们后面的其他对象,以及相互之间隐藏,等等。被遮挡静态是某些事物的特殊情况,例如始终需要渲染其他对象背后的透明对象,但它们自己如果被大物体遮挡其可见性时需要被隐藏。

自然地,因为静态标志必须启用才能进行遮挡剔除,所以此功能对动态对象不起作用。

下面的截图展示了为了演示目的,从外部视角如何有效地使用遮挡剔除来减少场景中渲染对象的数量。从主相机的视角来看,两种情况看起来是相同的。

渲染管线不会浪费时间渲染被更近的对象遮挡的对象:

图片

启用遮挡剔除功能将消耗额外的磁盘空间、RAM 和 CPU 时间。需要额外的磁盘空间来存储遮挡数据,需要额外的 RAM 来保持数据结构在内存中,并且每帧确定哪些对象被遮挡都会有 CPU 处理成本。遮挡剔除数据结构必须正确配置,以创建适合我们场景的适当大小的单元,单元越小,生成数据结构所需的时间越长。然而,如果为场景正确配置,遮挡剔除可以通过减少过度绘制和剔除不可见对象来提供填充率节省和绘制调用节省。

注意,即使一个对象可能被遮挡剔除,其阴影仍然需要计算,所以我们不会从这些任务中节省任何绘制调用或填充率。

优化粒子系统

粒子系统适用于大量不同的视觉效果,通常情况下,它们生成的粒子越多,效果看起来越好。然而,我们需要对生成的粒子数量和使用的着色器的复杂性负责,因为它们会影响到渲染管道的各个部分;它们为前端生成大量的顶点(每个粒子都是一个四边形)并可能使用多个纹理,这会在后端消耗填充率和内存带宽,因此如果使用不当,可能会使应用程序在任何地方都受限。

减少粒子系统的密度和复杂性相对简单——使用更少的粒子系统,生成更少的粒子,以及/或者使用更少的特殊效果。图集也是减少粒子系统性能成本的另一种常见技术。然而,粒子系统背后有一个重要的性能考虑因素并不太为人所知,并且发生在幕后,那就是自动粒子系统剔除的过程。

利用粒子系统剔除

基本思想是,所有粒子系统要么是可预测的,要么不是(确定性与非确定性),这取决于各种设置。当一个粒子系统是可预测的并且对主视图不可见时,整个粒子系统可以自动剔除以节省性能。一旦可预测的粒子系统重新进入视图,Unity 就可以确定粒子系统在那一刻应该看起来是什么样子,就像它一直在生成粒子一样,尽管它之前是不可见的。

只要粒子系统以非常程序化的方式生成粒子,状态就可以立即通过数学方法解决。

然而,如果任何设置迫使粒子系统变得不可预测或非程序化,那么它将无法知道如果之前被隐藏,粒子系统的当前状态需要是什么,因此将需要在每一帧完全渲染它,无论它是否可见。破坏粒子系统可预测性的设置包括但不限于使粒子系统在世界空间中渲染;应用外部力、碰撞和尾迹;或使用复杂的动画曲线。请参阅之前提到的博客文章,以获取非程序化条件的严格列表。

注意,当 Unity 检测到某些情况会导致粒子系统自动剔除功能失效时,它会提供一个有用的警告,如下面的截图所示:

图片

Unity Technologies 发布了一篇关于这个主题的优秀博客文章,可以在blogs.unity3d.com/2016/12/20/unitytips-particlesystem-performance-culling/找到。

避免递归调用粒子系统

可供ParticleSystem组件使用的许多方法都是递归调用。调用它们将遍历粒子系统的每个子代,然后对每个子代调用GetComponent<ParticleSystem>(),如果组件存在,它将调用相应的方法。然后,这将对原始父代下的每个ParticleSystem子代、孙子代等重复进行。这在具有深层粒子系统层次结构的情况下可能是一个大问题,这在复杂效果中有时是常见的情况。

受此行为影响的ParticleSystem API 调用有几个,例如Start()Stop()Pause()Clear()Simulate()isAlive()。显然,我们无法完全避免这些方法,因为它们代表了我们在粒子系统上最希望调用的最常见方法。然而,这些方法中的每个都有一个默认为truewithChildren参数。通过用false绕过此参数(例如,通过调用Clear(false)),它将禁用递归行为,并且不会调用其子代。因此,方法调用将仅影响给定的粒子系统,从而减少调用开销。

这并不总是理想的,因为我们通常希望粒子的所有子代都受到方法调用的影響。因此,另一种方法是,以我们在第二章“脚本策略”中学到的方式缓存ParticleSystem组件,并手动遍历它们(确保每次传递falsewithChildren参数)。

注意,在 Unity 2017.1 及更早版本中存在一个 bug,每次调用Stop()Simulate()时都会分配额外的内存(即使粒子系统已经被停止)。这个 bug 在 Unity 2017.2 中得到了修复。

优化 Unity UI

Unity 在内置 UI 系统方面的最初几次尝试并不特别成功;它们通常很快就被资产商店上的产品所取代。然而,他们最新一代的解决方案(简单地称为 Unity UI)已经变得非常受欢迎,以至于许多开发者开始依赖它来满足他们的 UI 需求,实际上,到了 2017 年初,Unity Technologies 收购了 Text Mesh Pro 资产背后的公司,并将其作为内置功能合并到 Unity UI 中。

让我们探索一些可以提高 Unity 内置 UI 性能的技术。

使用更多画布

Canvas组件的主要任务是管理用于在层次结构窗口中绘制 UI 元素及其下元素的网格,并发出必要的绘制调用。Canvas 的一个重要任务是批量处理这些网格(这只能在它们共享相同的材质时发生),以减少绘制调用。然而,当对 Canvas 或其任何子代进行更改时,这被称为污染Canvas。

当画布变脏时,它需要在发出绘制调用之前重新生成其下所有 UI 元素的网格。这个过程不是一项简单的任务,并且是 Unity 项目中性能问题的常见来源,因为不幸的是,许多事情都可以使画布变脏。甚至在一个画布内更改单个 UI 元素也可能导致这种情况发生。引起脏化的因素如此之多,而没有引起脏化的因素如此之少(通常只在某些情况下),因此最好谨慎行事,假设任何更改都会产生这种效果。

可能唯一不会引起脏化的操作是改变 UI 元素的Color属性。

如果我们发现我们的 UI 在每次发生变化时都会导致 CPU 使用量大幅上升(有时如果它们每帧都在变化,那么实际上每帧都会发生变化),我们可以应用的一个解决方案是简单地使用更多的画布。一个常见的错误是将整个游戏的 UI 构建在一个画布中,并且随着游戏代码和 UI 的复杂度增加,保持这种方式。

这意味着每次 UI 中任何内容发生变化时,都需要检查每个元素,随着更多元素被挤入单个画布,这可能会对性能造成越来越大的影响。然而,每个画布都是独立的,不需要与其他 UI 中的画布交互,因此通过将 UI 拆分为多个画布,我们可以分离工作量并简化单个画布所需的任务。

确保将GraphicsRaycaster组件添加到与子画布相同的GameObject中,以便其自身的子元素仍然可以交互。相反,如果画布的子元素没有任何交互性,那么我们可以安全地从中移除任何GraphicsRaycaster组件以降低性能成本。

在这种情况下,即使一个元素仍在变化,也需要重新生成的其他元素会更少,从而降低性能成本。这种方法的缺点是,不同画布上的元素不会一起批量处理,因此,如果可能的话,我们应该尽量将具有相同材质的相似元素分组放在同一个画布中。

同样,为了组织目的,也可以将一个画布作为另一个画布的子画布,并且适用相同的规则。如果一个画布中的元素发生变化,另一个画布将不受影响。

在静态和动态画布之间分离对象。

我们应该努力尝试根据元素更新的时间来生成画布。我们应该将我们的元素视为适合以下三个组之一:

  • 静态:静态 UI 元素是指永远不会变化的元素;这些元素的例子包括背景图像、标签等。

  • 偶然动态:动态元素是指可以变化的元素,而偶然动态对象是指那些仅对某些事件(如 UI 按钮点击或悬停操作)做出响应的 UI 元素。

  • 连续动态:连续动态对象是那些定期更新的 UI 元素,例如动画元素

我们应该尝试将 UI 元素从这三个组中分离出来,为 UI 的任何给定部分创建三个不同的 Canvas,这样将最小化再生过程中的浪费。

禁用非交互元素的 Raycast 目标

UI 元素有一个 Raycast 目标选项,它使它们能够通过点击、轻触和其他用户行为进行交互。每次发生这些事件之一时,GraphicsRaycaster组件将执行像素到边界框的检查,以确定哪个元素被交互,这是一个简单的迭代for循环。通过为非交互元素禁用此选项,我们减少了GraphicsRaycaster需要迭代的元素数量,从而节省了性能。

通过禁用父 Canvas 组件来隐藏 UI 元素

UI 使用一个独立的布局系统来处理某些元素类型的再生,它的工作方式与污染 Canvas 类似。UIImageUITextLayoutGroup都是属于这个系统的组件。许多事情都可以导致布局系统变得污染,其中最明显的是启用和禁用这些元素。然而,如果我们想禁用 UI 的一部分,我们可以通过简单地禁用它们所拥有的Canvas组件来避免布局系统中的这些昂贵的再生调用。这可以通过将Canvas组件的enabled属性设置为false来实现。

这种方法的缺点是,如果任何子对象具有一些Update()FixedUpdate()LateUpdate()或协程代码,那么我们也需要手动禁用它们,否则它们将继续运行。通过禁用Canvas组件,我们只是停止了 UI 的渲染和交互,我们应该期望各种更新调用继续正常发生。

避免使用 Animator 组件

Unity 的Animator组件从未打算与 UI 系统的最新版本一起使用,它们之间的交互是一个简单的实现。每一帧,动画师都会更改 UI 元素上的属性,导致它们的布局被污染并重新生成大量的内部 UI 信息。我们应该完全避免使用动画师,而是自己执行缓动或使用专为这些操作设计的实用资产。

明确定义世界空间 Canvas 的事件相机

画布可以用于 2D 和 3D 中的 UI 交互。这取决于画布的渲染模式设置是否配置为屏幕空间(2D)或世界空间(3D)。每次进行 UI 交互时,Canvas 组件都会检查其 eventCamera 属性(在检查器窗口中暴露为 Event Camera),以确定要使用哪个相机。默认情况下,2D 画布会将此属性设置为主相机,但 3D 画布将其设置为 null。这是不幸的,因为每次需要事件相机时,它仍然会使用主相机,但会通过调用 FindObjectWithTag() 来这样做。通过标签查找对象并不像使用其他 Find() 变化那样有性能成本,但其性能成本与在给定项目中使用的标签数量成线性关系。更糟糕的是,在给定帧中,对于世界空间画布,事件相机会被频繁访问,这意味着将此属性保留为 null 将导致巨大的性能损失,而没有任何实际的好处。我们应该手动将此属性设置为所有我们的世界空间画布的主相机。

不要使用 alpha 隐藏 UI 元素

在其 color 属性中使用 alpha 值为 0 的 UI 元素仍然会导致发出绘制调用。我们应该优先考虑更改 UI 元素的 IsActive 属性来在必要时隐藏它。另一个选择是使用通过 CanvasGroup 组件的 Canvas 组,这可以用来控制它们下方所有子元素的 alpha 透明度。将 Canvas 组的 alpha 值设置为 0 将剔除其子对象,因此不会发出绘制调用。

优化 ScrollRects

ScrollRect 组件是用于在列表中滚动其他 UI 元素的 UI 元素,在移动应用中相当常见。不幸的是,这些元素的性能与尺寸的缩放非常差,因为画布需要定期重新生成它们。我们可以做几件事情来提高我们的 ScrollRect 组件的性能。以下是一些方法。

确保使用 RectMask2D

通过简单地放置具有比 ScrollRect 元素更低 depth 值的其他 UI 元素,可以创建滚动 UI 行为。然而,这并不是一个好的实践,因为在 ScrollRect 中不会发生剔除,并且每个元素都需要在 ScrollRect 移动时为每一帧重新生成。如果我们还没有这样做,我们应该使用 RectMask2D 组件来剪辑和剔除不可见的子对象。此组件创建一个空间区域,其中任何位于其内的子 UI 元素如果超出 RectMask2D 组件的边界,将被剔除。与渲染过多不可见对象相比,确定是否剔除对象的成本通常是值得的。

禁用 ScrollRects 的像素完美

像素完美(Pixel Perfect)是Canvas组件上的一个设置,它强制其子 UI 元素以直接对齐屏幕上的像素进行绘制。这对于艺术和设计通常是必需的,因为 UI 元素将比禁用时看起来更清晰。虽然这种对齐行为是一个相对昂贵的操作,但对于动画和快速移动的对象来说,由于涉及到的运动,它可能有点没有意义。

禁用ScrollRect元素的像素完美(Pixel Perfect)设置是一种很好的节省资源的方法。然而,由于像素完美设置会影响整个画布,我们应该确保将ScrollRect元素作为一个子对象在单独的画布下启用,这样其他元素将保持其像素对齐的行为。

实际上,禁用像素完美(Pixel Perfect)后,不同类型的动画 UI 元素看起来更好。务必进行一些测试,因为这可以节省相当多的性能。

手动停止ScrollRect运动

即使每帧的速度只移动了像素的一部分,画布(Canvas)也总是需要重新生成整个ScrollRect元素。一旦我们检测到其速度低于某个阈值,我们可以使用ScrollRect.velocityScrollRect.StopMovement()手动冻结其运动。这可以大大减少重新生成的频率。

使用空UIText元素进行全屏交互

在大多数用户界面(UIs)中,常见的实现方式是激活一个覆盖整个屏幕的大透明交互元素,迫使玩家在继续之前处理一个弹出窗口,同时仍然允许玩家看到其背后的内容(作为不让玩家完全脱离游戏体验的手段)。这通常使用一个UIImage元素来完成,但不幸的是,这可能会破坏批处理操作,并且在移动设备上透明度可能成为问题。

解决这个问题的一个技巧是使用一个没有字体或文本定义的UIText元素。这创建了一个不需要生成任何可渲染信息的元素,并且只处理交互的边界框检查。

检查 Unity UI 源代码

如果我们的 UI 性能存在重大问题,查看源代码以确定可能发生的问题并希望发现绕过问题的方法是有可能的。

一个更激进的措施,但可能是一个选项,是实际上修改 UI 代码,编译它,并将其手动添加到我们的项目中。

Unity 在其 UI 系统的一个 Bitbucket 仓库中提供了代码,位于bitbucket.org/Unity-Technologies/ui

检查文档

之前提到的技巧是一些较为隐蔽、未记录或关键的性能优化技巧,适用于 UI 系统。Unity 网站上有多篇优秀的资源解释了 UI 系统的工作原理以及如何最佳优化它,这些内容太多,无法完整地放入本书中。

从以下页面开始,逐步阅读以获取更多有用的 UI 优化技巧:unity3d.com/learn/tutorials/temas/best-practices/guide-optimizing-unity-ui

着色器优化

片段着色器是填充率和内存带宽的主要消费者。其成本取决于它们的复杂度——包括纹理采样量、使用的数学函数数量以及许多其他因素。GPU 的并行特性(将整个工作的小部分分配给数百个线程)意味着任何线程中的瓶颈都会限制在帧内可以通过该线程的片段数量。

经典的类比是汽车装配线。一辆完整的汽车需要多个制造阶段才能完成。完成的关键路径可能包括冲压、焊接、喷漆、组装和检查,每个步骤都由一个团队完成。对于任何给定的车辆,任何阶段都不能在之前的阶段完成之前开始,但处理最后一辆车冲压的团队可以在完成工作后立即开始为下一辆车冲压。这种组织方式允许每个团队成为其特定领域的专家,而不是试图过于分散他们的知识,这可能会导致车辆批次的质量不一致。

我们可以通过增加团队数量来加倍整体输出,但如果任何团队受阻,那么任何给定车辆以及所有未来将通过同一团队的车辆都会损失宝贵的时间。如果这些延迟很少,那么在整体方案中它们可以忽略不计,但如果不是这样,即使每个阶段每次都要比正常情况下多花几分钟才能完成任务,那么它可能成为一个瓶颈,威胁到整个批次的发布。

GPU 的并行处理器工作方式类似:每个处理器线程是一个装配线,每个处理阶段是一个团队,每个片段是需要构建的东西。如果线程在一个阶段上花费了很长时间,那么每个片段都会损失时间。这种延迟会成倍增加,以至于所有通过同一线程的未来片段都会被延迟。这有点过于简化,但它经常有助于描绘一些优化不良的着色器代码如何迅速消耗我们的填充率,以及着色器优化的小幅改进如何在后端性能上带来巨大好处。

着色器编程和优化是游戏开发中的一个非常狭窄的领域。它们的抽象和高度专业化的性质需要一种非常不同的思维方式来生成高质量的着色器代码,与典型的游戏玩法或引擎代码相比。它们通常包含数学技巧和后门机制,用于将数据拉入着色器,例如预先计算值并将它们放入纹理文件中。正因为如此,以及优化的重要性,着色器往往很难阅读和逆向工程。

因此,许多开发者依赖于预写的着色器,来自 Asset Store 的视觉着色器创建工具,如 Shader Forge 或 Amplify Shader Editor。这简化了初始着色器代码生成的过程,但可能不会产生最有效的着色器形式。无论我们是编写自己的着色器,还是依赖于预写/预生成的着色器,我们可能会发现使用一些经过验证的技术对它们进行一些优化是有价值的,这些技术我们将在以下章节中看到。

考虑使用针对移动平台设计的着色器

Unity 中内置的移动端着色器没有特定的限制,迫使它们只能在移动设备上使用。它们只是针对最小资源使用进行了优化(并且往往具有本节中列出的其他一些优化)。

桌面应用程序完全可以使用这些着色器,但它们往往会导致图形质量的损失。这仅仅是一个问题,即图形质量的损失是否可以接受。因此,考虑使用常见着色器的移动端等效产品进行一些测试,以检查它们是否适合您的游戏。

使用小型数据类型

GPU 可以使用比大型数据类型更小的数据类型更快地计算(尤其是在移动平台上),因此我们可以尝试的第一个调整是将我们的float数据类型(32 位,浮点数)替换为更小的版本,例如half(16 位,浮点数)或甚至fixed(12 位,定点数)。之前列出的数据类型的大小将取决于目标平台首选的浮点数格式。列出的尺寸是最常见的。

优化源于格式之间的相对大小,因为要处理的位数更少。

颜色值是进行精度降低的良好候选者,因为我们通常可以减少精确的颜色值数量,而不会在色彩上产生明显的损失。然而,降低精度的效果对于图形计算来说可能非常不可预测。因此,这些变化可能需要一些测试来验证降低精度是否导致图形保真度损失过多。

注意,这些调整的效果在不同 GPU 架构之间(例如,AMD 与 Nvidia 与 Intel)以及同一制造商的 GPU 品牌之间可能会有很大差异。在某些情况下,我们只需付出微小的努力,就能获得一些相当的性能提升。在其他情况下,我们可能根本看不到任何好处。

避免在混色时更改精度

混色是着色器编程技术,通过列出我们希望按顺序复制到新结构中的组件来从现有向量创建一个新的向量(值数组)。

下面是一些混色的示例:

float4 input = float4(1.0, 2.0, 3.0, 4.0);  // initial test value (x, y, z, w)

// swizzle two components
float2 val1 = input.yz; // val1 = (2.0, 3.0)

// swizzle three components in a different order
float3 val2 = input.zyx; // val2 = (3.0, 2.0, 1.0)

// swizzle the same component multiple times
float4 val3 = input.yyy; // val3 = (2.0, 2.0, 2.0)

// swizzle a scalar multiple times
float sclr = input.w; // sclr = (4.0)
float3 val4 = sclr.xxx; // val4 = (4.0, 4.0, 4.0)

我们可以使用xyzwrgba表示法来引用相同的组件,顺序无关紧要。无论是颜色还是向量,它们只是使着色器代码更容易阅读。我们还可以按任何我们喜欢的顺序列出组件,以填充所需的数据,如果需要可以重复。

在着色器中将一个精度类型转换为另一个类型可能是一个昂贵的操作,但在同时进行混色转换时可能会特别痛苦。如果我们有使用混色的数学运算,确保它们不会同时转换精度类型。在这些情况下,最好从一开始就简单地使用高精度数据类型,或者全面降低精度以避免需要更改精度。

使用 GPU 优化的辅助函数

着色器编译器通常能很好地将数学计算优化为适合 GPU 的版本,但编译的定制代码可能不如Cg库的内置辅助函数以及 Unity Cg包含文件提供的额外辅助函数有效。如果我们使用包含定制函数代码的着色器,也许我们可以在Cg或 Unity 库中找到一个等效的辅助函数,它比我们的定制代码做得更好。

这些额外的include文件可以添加到我们的着色器中的CGPROGRAM块中,如下所示:

CGPROGRAM
// other includes
#include "UnityCG.cginc"
// Shader code here
ENDCG

可以使用的示例Cg库函数包括abs()用于绝对值,lerp()用于线性插值,mul()用于矩阵乘法,以及step()用于步进功能。有用的UnityCG.cginc函数包括WorldSpaceViewDir()用于计算朝向摄像机的方向和Luminance()用于将颜色转换为灰度。

请参阅http.developer.nvidia.com/CgTutorial/cg_tutorial_appendix_e.html以获取 Cg 标准库函数的完整列表。

请参阅 Unity 文档,以获取可能的include文件及其相关辅助函数的完整和最新列表,请访问docs.unity3d.com/Manual/SL-BuiltinIncludes.html

禁用不必要的功能

也许我们可以通过简单地禁用非关键着色器功能来节省一些资源。着色器真的需要透明度、Z-写入、alpha 测试和/或 alpha 混合吗?调整这些设置或删除这些功能是否会给我们一个很好的近似效果,而不会损失太多的图形保真度?进行这样的更改是节省填充率成本的好方法。

移除不必要的输入数据

有时,编写着色器的过程涉及到在编辑代码和查看场景中的代码之间进行大量的来回实验。这个过程典型的结果是在着色器早期开发时需要的输入数据,一旦达到期望的效果,就变成了多余的冗余数据,而且如果这个过程拖得时间过长,很容易忘记都做了哪些更改。然而,这些冗余的数据值可能会让 GPU 付出宝贵的时间,因为即使它们没有被着色器明确使用,也必须从内存中检索。因此,我们应该仔细检查我们的着色器,以确保它们的所有输入几何、顶点和片段数据实际上正在被使用。

仅暴露必要的变量

将我们着色器中的不必要的变量暴露给配套的材质可能会造成成本,因为 GPU 不能假设这些值是恒定的,这意味着编译器不能将这些值编译掉。这些数据必须每次从 CPU 推送,因为它们可以通过材质对象的SetColor()SetFloat()等方法在任意时刻被修改。如果我们发现,在项目后期,我们总是使用这些变量的相同值,那么它们应该被着色器中的常量替换,以消除这种过度的运行时工作负载。唯一的成本是混淆可能至关重要的图形效果参数,因此这应该在过程的后期进行。

降低数学复杂性

复杂的数学运算可能会严重限制渲染过程,因此我们应该尽一切可能来限制这种损害。通过预先计算并将它们作为浮点数据放置在纹理文件中,可以完全存储复杂数学函数输出的映射。毕竟,纹理文件只是一个由浮点值组成的巨大块,可以用三个维度快速索引:xy和颜色(rgba)。

我们可以将这个纹理输入到着色器中,并在运行时从着色器中采样预先生成的表格,而不是在运行时完成复杂的计算。

对于像sin()cos()这样的函数,我们可能看不到任何改进,因为它们已经被高度优化以利用 GPU 架构,但像pow()exp()log()以及我们自己的自定义数学计算这样的复杂方法只能优化到一定程度,并且会是简化的良好候选者。这是假设我们可以轻松地使用xy坐标索引结果。如果需要复杂的计算来生成这些坐标,那么可能不值得付出努力。

这种技术将使我们额外的图形内存来存储运行时的纹理,以及一些内存带宽,但如果着色器已经接收到了纹理(在大多数情况下是这样的),但 alpha 通道没有被使用,那么我们可以通过纹理的 alpha 通道偷偷地传输数据,实际上不会影响性能,因为数据已经无论如何都通过了。这将涉及到手动编辑我们的艺术资产,以包括任何未使用的颜色通道中的数据,可能需要程序员和艺术家之间的协调,但这是一个非常好的方法,可以在不牺牲运行时的情况下节省着色器处理成本。

减少纹理采样

纹理采样是所有内存带宽成本的核心。我们使用的纹理越少,它们越小,就越好。我们使用的越多,我们可能遇到的缓存未命中就越多,它们越大,传输到纹理缓存所需的内存带宽就越多。这些情况应该尽可能地简化,以避免严重的 GPU 瓶颈。

更糟糕的是,以非顺序的方式采样纹理可能会给 GPU 带来一些非常昂贵的缓存未命中。所以,如果这样做,那么纹理应该重新排序,以便可以以更顺序的方式采样。例如,如果我们是通过反转xy坐标来采样(例如,tex2D(y, x)而不是tex2D(x, y)),纹理查找将垂直迭代通过纹理,然后水平迭代,几乎每次迭代都会发生缓存未命中。通过简单地旋转纹理文件数据并在正确的顺序中进行采样(tex2D(x,y)),可以节省大量的性能。

避免条件语句

当条件语句在现代 CPU 上运行时,它们会经历许多聪明的预测技术来利用指令级并行性。这是一个功能,CPU 试图在条件语句实际解决之前预测其将采取的方向,并使用任何未用于解决条件的空闲核心(从内存中获取一些数据,将一些浮点值复制到未使用的寄存器等)来投机性地开始处理条件最可能的结果。如果最终证明这个决定是错误的,那么当前的结果将被丢弃,并采取正确的路径。只要投机处理和丢弃错误结果的成本低于等待决定正确路径的时间,并且它正确的情况比错误的情况多,那么这对 CPU 的速度来说就是一个净收益。

然而,由于 GPU 架构的并行性质,这个特性对 GPU 来说不太有益。GPU 的核心通常由一些高级结构管理,该结构指示其命令下的所有核心同时执行相同的机器代码级指令,例如一个巨大的冲压机,可以同时冲压金属板。因此,如果片段着色器需要将float乘以2,那么这个过程将开始于所有核心在一步中协调地将数据复制到适当的寄存器中。只有当所有核心都完成复制到寄存器后,核心才会被指示开始第二步:在第二步中,所有寄存器同时乘以2

因此,当这个系统遇到一个条件语句时,它不能独立解决这两个语句。它必须确定其子核心中有多少会走条件语句的每条路径,获取一条路径所需的机器代码指令列表,为所有走这条路径的核心解决这些指令,并重复这些步骤,直到处理完所有可能的路径。所以,对于一个if-else语句(两种可能性),它会告诉一组核心处理true路径,然后要求剩余的核心处理false路径。除非每个核心都走相同的路径,否则它必须每次都处理两条路径。

因此,我们应该在我们的着色器代码中避免分支和条件语句。当然,这取决于条件语句对于实现我们想要的图形效果有多重要。然而,如果条件语句不依赖于每像素的行为,那么我们通常会更好,吸收不必要的数学成本,而不是在 GPU 上施加分支成本。

减少数据依赖

编译器会尽力将我们的着色器代码优化成更符合 GPU 的低级语言,以便它不会在等待数据被获取时处理其他任务。例如,以下优化不良的代码可以写在我们的着色器中:

float sum = input.color1.r;
sum = sum + input.color2.g;
sum = sum + input.color3.b;
sum = sum + input.color4.a;
float result = calculateSomething(sum);

这段代码有一个数据依赖,即每个计算必须在最后一个完成之前开始,因为依赖于sum变量。然而,这种情况通常会被着色器编译器检测到,并优化成一个使用指令级并行的版本。以下代码是编译前代码的结果机器代码的高级代码等效。

float sum1, sum2, sum3, sum4;
sum1 = input.color1.r;
sum2 = input.color2.g;
sum3 = input.color3.b;
sum4 = input.color4.a;
float sum = sum1 + sum2 + sum3 + sum4;
float result = CalculateSomething(sum);

在这种情况下,编译器会认识到它可以并行从内存中获取四个值,并在所有四个值都独立通过线程级并行性获取后完成求和。这相对于依次执行四个获取可以节省大量时间。

然而,无法编译消除的长链数据依赖关系绝对会摧毁着色器的性能。如果我们在我们着色器源代码中创建一个强大的数据依赖,那么它就没有自由去进行任何优化。例如,以下的数据依赖会对性能造成痛苦,因为一个步骤实际上不能完成,除非等待另一个步骤去获取数据,因为采样每个纹理都需要先采样另一个纹理,而编译器无法假设数据在此期间没有发生变化。

以下代码表示指令之间存在非常强的数据依赖,因为每个指令都依赖于从上一个指令中采样到的纹理数据:

float4 val1 = tex2D(_tex1, input.texcoord.xy);
float4 val2 = tex2D(_tex2, val1.yz); // requires data from _tex1
float4 val3 = tex2D(_tex3, val2.zw); // requires data from _tex2

应尽可能避免这种强大的数据依赖。

表面着色器

Unity 的表面着色器是片段着色器的一种简化形式,允许 Unity 开发者以更简化的方式掌握着色器编程。Unity 引擎会为我们处理转换我们的表面着色器代码,抽象掉我们刚刚提到的某些优化机会。然而,它确实提供了一些可以用来替换的杂项值,这些值降低了精度但简化了结果代码中的数学。表面着色器旨在高效地处理一般情况,但最佳优化是通过编写我们自己的着色器来实现,带有个人风格。

approxview 属性将近似视图方向,节省昂贵的操作。halfasview 属性将降低视图向量的精度,但要注意其对涉及多精度类型的数学运算的影响。noforwardadd 属性将限制着色器只考虑单一方向光源,减少绘制调用,因为着色器将只进行一次渲染,并降低光照复杂性。最后,noambient 属性将在着色器中禁用环境光照,移除一些可能不需要的额外数学运算。

使用基于着色器的 LOD

我们可以强制 Unity 使用更简单的着色器渲染远距离对象,这可以是一种有效的节省填充率的方法,尤其是如果我们将游戏部署到多个平台或支持广泛的硬件能力时。可以在着色器中使用LOD关键字来设置着色器支持的屏幕尺寸因子。如果当前 LOD 级别不匹配此值,它将降级到下一个后备着色器,依此类推,直到找到支持给定尺寸因子的着色器。我们还可以在运行时使用maximumLOD属性更改给定着色器对象的 LOD 值。

此功能与之前介绍的基于网格的 LOD 类似,并使用相同的 LOD 值来确定对象形状因子,因此应按此方式配置。

有关基于着色器的 LOD 的更多信息,请参阅 Unity 文档中的docs.unity3d.com/Manual/SL-ShaderLOD.html

使用更少的纹理数据

这种方法简单直接,始终是一个好主意考虑。通过分辨率或比特率降低纹理质量,对图形质量来说并不理想,但我们有时可以使用 16 位纹理而不会出现任何明显的降级。

Mipmaps(在第四章“优化您的艺术资源”中探讨)是减少 VRAM 和纹理缓存之间来回传输的纹理数据量的另一种优秀方法。请注意,场景窗口有一个 Mipmaps 着色模式,它将根据当前纹理比例是否适合当前场景窗口的相机位置和方向,将场景中的纹理以蓝色或红色突出显示。这将有助于识别哪些纹理是进一步优化的良好候选者。

测试不同的 GPU 纹理压缩格式

如您在第四章“优化您的艺术资源”中学习到的,存在不同的纹理压缩格式,这些格式可以减少我们应用程序的磁盘占用(可执行文件大小)、运行时 CPU 和 RAM 使用。这些压缩格式旨在支持给定平台的 GPU 架构。有众多不同的格式,如 DXT、PVRTC、ETC 和 ASTC,但在特定平台上只有少数几种可用。

默认情况下,Unity 将选择由纹理文件的压缩设置确定的最佳压缩格式。如果我们深入到特定纹理文件的平台特定选项,那么将提供不同的压缩类型选项,列出给定平台支持的纹理格式。我们可能可以通过覆盖默认的压缩选择来找到一些空间或性能上的节省。

虽然要注意,如果我们已经到了需要单独调整纹理压缩技术的地步,那么我们可能已经用尽了所有其他减少内存带宽的选项。沿着这条路走下去,我们将承诺支持许多不同的设备,每种设备都有其特定的方式。许多开发者宁愿使用通用解决方案来保持事情简单,而不是进行个人定制和耗时的人工操作以换取微小的性能提升。

查阅 Unity 文档以了解所有可用的不同纹理格式以及 Unity 默认首选的格式,请参阅docs.unity3d.com/Manual/class-TextureImporterOverride.html

在 Unity 的旧版本中,所有格式都对外部高级纹理类型开放,但如果平台不支持给定的类型,它将在软件级别进行处理。换句话说,CPU 需要停止并重新压缩纹理到 GPU 想要的格式,而不是由 GPU 通过专门的硬件芯片来处理。Unity Technologies 决定在较新版本中移除此功能,以避免意外引发这些问题。

最小化纹理交换

这一点相当直接。如果内存带宽有问题,那么我们需要减少我们正在进行的纹理采样的数量。在这里实际上没有特别的技巧可以利用,因为内存带宽完全是关于吞吐量,所以主要考虑的指标是我们推送的数据量。

减少体积的一种方法是通过简单地降低纹理分辨率和质量来实现。这显然不是理想的选择,因此另一种方法是找到巧妙的方法在不同的网格上重复使用纹理,但使用不同的材质和着色器属性。例如,一个适当变暗的砖块纹理看起来可能像一块石头墙。当然,这将需要不同的渲染状态,因此我们不会节省绘制调用,但它可以减少内存带宽消耗。

你是否曾注意到在超级马里奥兄弟中云和灌木看起来完全一样,但颜色不同?这是同样的概念。

还可能有将纹理组合到图集中的方法来减少所需的交换次数。如果有几组纹理总是在相似的时间一起使用,那么它们可能可以合并在一起。这可以节省 GPU 在相同帧内反复拉取单独的纹理文件。

最后,完全从应用程序中移除纹理始终是我们能采取的最后手段。

VRAM 限制

与纹理相关的一个最后考虑因素是我们有多少可用的 VRAM。大多数从 CPU 到 GPU 的纹理传输发生在初始化期间,但也可以在当前视图首次需要不存在的纹理时发生。这个过程通常是异步的,并且会导致使用空白纹理,直到完整的纹理准备好进行渲染(参考第四章,优化您的艺术资产,注意这假设对纹理的读写访问已被禁用)。因此,我们应该避免在运行时过于频繁地引入新的纹理。

使用隐藏的 GameObject 预加载纹理

在异步纹理加载期间使用的空白纹理在游戏质量方面可能会令人不快。我们希望有一种方法来控制和强制纹理从磁盘加载到 RAM,然后再加载到 VRAM,在它实际需要之前。

一个常见的解决方案是创建一个使用纹理的隐藏GameObject,并将其放置在场景中玩家将前往的区域的路径上。一旦玩家看向该对象,渲染管线就需要该纹理(即使它技术上被隐藏),它将开始从 RAM 复制数据到 VRAM 的过程。这有点笨拙,但易于实现,并且在大多数情况下效果足够好。

我们也可以通过脚本代码通过更改材质的texture属性来控制这种行为:

GetComponent<Renderer>().material.texture = textureToPreload;

避免纹理抖动

在极少数情况下,如果加载到 VRAM 的纹理数据过多,并且所需的纹理不存在,GPU 将需要从 RAM 请求它,并覆盖一个或多个现有纹理以腾出空间。随着时间的推移,内存变得碎片化,这可能会加剧问题,并且它引入了风险,即刚刚从 VRAM 中清除的纹理需要在同一帧内再次拉取。这将导致严重的内存抖动,应该不惜一切代价避免。

在现代控制台如 PS4、Xbox One 和 Wii U 上,这个问题不太令人担忧,因为它们共享 CPU 和 GPU 的公共内存空间。这种设计是硬件级别的优化,考虑到设备始终运行单个应用程序,并且几乎总是渲染 3D 图形。然而,大多数其他平台必须与多个应用程序共享时间和空间,其中 GPU 只是一个可选设备,并不总是存在。因此,它们为 CPU 和 GPU 提供独立的内存空间,我们必须确保在任何给定时刻的总纹理使用量都低于目标硬件可用的 VRAM。

注意,这种抖动并不完全等同于硬盘抖动,其中内存在主内存和虚拟内存(交换文件)之间来回复制,但它具有相似性。在任何情况下,数据都在两个内存区域之间不必要地来回复制,因为请求的数据量太大,以至于较小的内存区域无法容纳。

当游戏从现代游戏机移植到桌面平台时,这种类型的过度渲染可能是导致糟糕渲染性能的常见原因,因此应该谨慎处理。

避免这种行为可能需要根据每个平台和每个设备定制纹理质量和文件大小。请注意,如果我们处理的是同一台游戏机或桌面 GPU 代系的硬件,一些玩家可能会注意到这些不一致性。正如我们许多人所知,即使是硬件上的微小差异也可能导致大量的苹果与橙子比较,但硬核玩家通常期望在所有方面都能达到相似的质量水平。

光照优化

在本章前面我们已经讨论了光照行为理论,现在让我们回顾一下我们可以使用的技巧来提高光照成本。

负责任地使用实时阴影

如前所述,阴影很容易成为消耗绘制调用和填充率最大的因素之一,因此我们应该花时间调整这些设置,直到我们获得所需的性能和/或图形质量。

在“编辑 | 项目设置 | 质量 | 阴影”下可以找到多个重要的阴影设置。就阴影选项而言,软阴影成本较高,硬阴影成本较低,无阴影则是免费的。阴影分辨率、阴影投影、阴影距离和阴影级联也是影响我们阴影性能的重要设置:

图片

阴影距离是运行时阴影渲染的全局乘数。在相机远处渲染阴影几乎没有意义,因此这个设置应根据我们的游戏和预期在游戏过程中看到的阴影量进行配置。它也是一个常见的设置,通常在选项屏幕中向用户公开,他们可以选择渲染阴影的距离以使游戏性能与他们的硬件相匹配(至少在桌面机器上)。

高阴影分辨率和阴影级联的值会增加我们的内存带宽和填充率消耗。这两个设置都可以帮助减轻由阴影渲染产生的伪影效果,但代价是更大的阴影贴图纹理尺寸,这会增加内存带宽和 VRAM 的使用。

Unity 文档中对阴影贴图别名效应的总结以及阴影级联功能如何帮助解决这个问题有很好的概述,请参阅docs.unity3d.com/Manual/DirLightShadows.html

值得注意的是,与硬阴影相比,软阴影不会消耗更多的内存或 CPU 开销,因为唯一的区别是更复杂的着色器。这意味着具有足够填充率的程序可以享受软阴影带来的改进的图形保真度。

使用剔除掩码

Light组件的裁剪遮罩属性是一个基于层的遮罩,可以用来限制受给定光照影响的物体。这是一个有效减少光照开销的方法,假设层交互也符合我们使用层进行物理优化的方式。物体只能属于单个层,并且减少物理开销在大多数情况下可能比减少光照开销更重要;因此,如果存在冲突,那么这种方法可能不是最佳选择。

注意,在使用延迟着色时,对裁剪遮罩的支持有限。由于它以非常全局的方式处理光照,遮罩中只能禁用四个层,这限制了优化其行为的能力。

使用烘焙光照贴图

将光照和阴影烘焙到场景中,与在运行时生成它们相比,处理器消耗显著降低。缺点是增加了应用程序的磁盘占用、内存消耗和潜在的内存带宽滥用。最终,除非游戏的光照效果完全通过传统的顶点光照着色格式或通过单个DirectionalLight实例处理,否则可能应该在某个地方包含光照贴图,以在光照计算上节省大量预算。完全依赖实时光照和阴影可能会导致灾难,因为它们可能带来的性能成本。

几个指标可以影响光照贴图的成本,例如它们的分辨率、压缩、我们是否使用预计算的实时全局光照,以及当然,场景中物体的数量。光照贴图生成覆盖场景中所有标记为光照贴图静态的物体的纹理,因此,我们拥有的越多,必须为它们生成的纹理数据就越多。

这将是一个利用加法或减法场景加载来最小化每帧需要处理的物体数量的机会。当然,当加载多个场景时,这会引入更多的光照贴图数据,因此我们应该预期每次发生这种情况时,内存消耗都会大幅增加,只有在旧场景卸载后才会释放。

优化移动设备的渲染性能

Unity 部署到移动设备的能力极大地促进了它在业余爱好者、小型和中型开发团队中的普及。因此,明智的做法是介绍一些比桌面和其他设备更有益于移动平台的方法。让我们来看看这些方法中的几个。

注意,以下任何或所有方法最终可能变得过时,至少对于新设备来说是这样。移动设备的性能已经飞速发展,以下技术作为移动设备的应用,仅仅反映了过去五年左右的常规智慧。我们应该测试这些方法背后的假设,以检查移动设备的限制是否仍然适合移动市场。

避免 alpha 测试

移动 GPU 尚未达到桌面 GPU 相同的芯片优化水平,alpha 测试在移动设备上尤其成本高昂。在大多数情况下,应简单地避免 alpha 测试,转而使用 alpha 混合。

最小化绘制调用

移动应用程序在绘制调用上的瓶颈通常比填充率要高。虽然不能忽视填充率的问题(永远不能忽视!),但这几乎使得任何质量合理的移动应用程序在最初就必须实现网格合并、批处理和纹理图集技术。延迟渲染也是首选技术,因为它很好地适应了其他移动特定的问题,例如避免透明度和拥有过多的动画角色,但当然,并非所有移动设备和图形 API 都支持它。

查阅 Unity 文档以获取有关哪些平台/API 支持延迟着色的更多信息,请访问docs.unity3d.com/Manual/RenderingPaths.html

最小化材质数量

这个问题与批处理和纹理图集的概念密切相关。我们使用的材质越少,所需的绘制调用就越少。这种策略还将有助于与 VRAM 和内存带宽相关的问题,这些在移动设备上通常非常有限。

最小化纹理大小

与桌面 GPU 相比,大多数移动设备的纹理缓存非常小。市场上仍然支持 OpenGL ES 1.1 或更低版本的设备非常少,例如 iPhone 3G,但这些设备只能支持最大 1024 x 1024 的纹理大小。支持 OpenGLES 2.0 的设备,例如从 iPhone 3GS 到 iPhone 6S 的所有设备,可以支持高达 2048 x 2048 的纹理。最后,支持 OpenGLES 3.0 或更高版本的设备,例如运行 iOS 7 的设备,可以支持高达 4096 x 4096 的纹理。

这里无法列出所有 Android 设备,但 Android 开发者门户提供了一个方便的 OpenGLES 设备支持的细分。这些信息定期更新,以帮助开发者确定 Android 市场上支持的 API,请访问developer.android.com/about/dashboards/index.html

仔细检查我们针对的设备硬件,以确保它支持我们希望使用的纹理文件大小。然而,新一代设备永远不会是移动市场上的最常见设备。如果我们希望我们的游戏能够触及广泛的受众(增加其成功的可能性),那么我们必须愿意支持较弱的硬件。

注意,对于 GPU 无法处理的过大纹理,CPU 将在初始化过程中将其降级。这浪费了宝贵的加载时间,并可能导致由于分辨率的无控制降低而造成的不预期的质量损失。这使纹理重用在移动设备上变得极为重要,因为可用的 VRAM 和纹理缓存大小有限。

使纹理成为正方形和 2 的幂次

我们已经在第四章“优化您的艺术资产”中讨论了这一主题,但回顾 GPU 级纹理压缩的问题仍然很有价值。如果纹理不是正方形格式,GPU 将难以压缩纹理,或者根本无法压缩,因此请确保您坚持常见的开发约定,保持正方形并按 2 的幂次大小进行。

在着色器中使用最低可能的精度格式

移动 GPU 对其着色器中的精度格式特别敏感,因此应使用最小的格式,例如half。相关地,出于同样的原因,应尽量避免进行精度格式转换。

摘要

如果您没有跳过前面的内容,那么恭喜您。对于 Unity 引擎的一个子系统来说,这已经是一个需要吸收大量信息的内容,但显然它是其中最复杂的,需要相应的深度解释。希望您已经学到了许多有助于提高渲染性能的方法,以及足够关于渲染管道的知识,以便能够负责任地使用它们。

到现在为止,我们应该已经习惯了这样的想法,即除了算法改进之外,我们实施的每一项性能提升都将带来一些相关的成本,我们必须愿意承担这些成本,以消除一个瓶颈。我们应该随时准备实施多种技术,直到我们全部克服它们,并且可能需要花费大量的额外开发时间来实施和测试一些性能增强功能。

在下一章中,让我们通过探索可以应用于 VR 和 AR 项目的性能改进,将性能优化带入现代时代。

第三部分:高级优化

读者将学习如何实现额外的和更高级/情境化的优化技术。本节中的章节如下:

第七章, 虚拟和增强现实优化

第八章, 精湛的内存管理

第九章, 面向数据的科技栈

第十章, 战术技巧与窍门

第七章:虚拟和增强现实优化

两种全新的娱乐媒介以虚拟现实VR)和增强现实AR)的形式进入了世界舞台。在头戴式设备HMD)的使用下,用户被带入一个虚拟空间。在增强现实中,虚拟元素叠加在显示真实世界的显示之上。为了简洁起见,这两个术语通常被合并成一个单一术语——扩展现实XR)。还有混合现实MR)(也称为混合现实HR)),其中应用程序将真实世界和虚拟世界混合在一起;这包括所有之前提到的格式,同时也包括 AR,其中真实世界物体被扫描并叠加在一个主要虚拟世界中。

这些媒体格式的市场迅速崛起,并且仍在快速增长,吸引了科技行业最大玩家的巨额投资。自然地,像 Unity 这样的游戏引擎迅速加入了这一行列,为大多数顶级竞争平台提供了充足的支持,例如谷歌的 Cardboard、宏达电的 VIVE、Oculus Rift、微软的 HoloLens 以及三星的 Gear VR 平台,以及更近期的加入者,如苹果的 ARKit、谷歌的 ARCore、微软的 Windows 混合现实平台、PTC 的(最初是高通的)Vuforia 和索尼的 PlayStation VR。

XR 技术概述

XR 为开发者和创意人士提供了一个全新的探索领域。这包括娱乐产品,如游戏和 360 度视频(或简称 360 视频),其中一系列摄像头捆绑在一起,每个摄像头面向不同的方向——从这些摄像头捕获的各种图像被拼接在一起,并在 VR 头盔中像电影一样播放,实现全方位的可见性。创意行业工具在 XR 中也很常见,如 3D 建模软件、工作流程可视化以及提高生活质量的设备。已经确立的规则很少,因此有大量的创新机会,为这股新技术浪潮做出贡献,并成为制定这些规则的人。这导致了许多关于探索可能性和尝试在娱乐和互动体验的未来上留下印记的喧嚣和兴奋。

当然,几乎每一种新技术都会经历炒作周期(来自 Gartner 炒作周期,您可以在www.gartner.com/technology/research/methodologies/hype-cycle.jsp查看)。炒作周期始于过度炒作的蜜月期,早期采用者会宣扬其好处。随后,随着它进入幻灭的低谷,情绪最终会冷却,因为它还没有完全进入主流,其好处尚未得到充分认可. 这种情况会持续,直到技术要么未能赢得人心,从而消失,要么稳固地占据市场并持续稳定地被采用。以下图表展示了 Gartner 炒作周期的关键要素:

图片

争议性地,XR 最近可能正在经历这个最终阶段,并开始享受到比早期更好的支持和更高品质的体验,尽管 XR 的采用率确实比最初预测的要慢。是否 XR 将成长为一个数十亿美元的行业,或者仅仅消失在利基市场中,还有待观察。因此,在这个新媒介中开发并非没有风险,我们可以找到同意我们观点的行业分析师,无论我们对 XR 的未来持何种立场。有一点是肯定的:每当有人亲身体验 VR 和 AR 的能力时,他们都会被沉浸感和该媒介将他们成功地带入另一个世界的说服力所震撼。这种沉浸感和互动性是无与伦比的,并且随着平台的支持成熟和技术持续进步,预示着更多的可能性。

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

  • 在 Unity 中开发 VR 或 AR 项目时需要考虑的担忧以及必须避免的事项

  • 专门针对 XR 媒介的性能提升

开发 XR 产品

在 Unity 中开发 XR 产品涉及将几个 XR 软件开发工具包SDKs)之一导入我们的 Unity 项目中,并在运行时通过一些专门的 API 调用配置和使用该平台。每个 SDK 都有其独特之处,并提供不同的功能集。例如,Oculus Rift 和 HTC VIVE SDKs 提供了控制 VR 头戴式显示器及其相应控制器的 API,而苹果的 ARKit 提供了确定空间定位和在显示上叠加对象的实用工具。Unity Technologies 一直在努力创建支持所有这些变化的 API,因此 Unity 中 XR 开发的 API 在过去几年中发生了很大变化。

Unity VR 开发的早期意味着将原生插件直接放入我们的 Unity 项目中,直接从外部开发者门户导入 SDK(涉及设置中的各种令人烦恼的繁琐工作),并手动应用更新。然而,从那时起,Unity 已经将这些 SDK 直接集成到编辑器中。此外,由于 AR 最近变得更加流行,主 API 在 Unity 2017.2.0 及以后的版本中已被重命名为UnityEngine.VRUnityEngine.XR,并进行了修改,以便它可以与多个 AR SDK 一起工作。

Unity XR 系统目前正在从传统模式过渡到新的基于包的模式。默认情况下,Unity 支持有限的 XR 平台。要导入其他 XR SDK 并对其进行配置(例如 ARKit 或 Hololens),您需要首先通过前往 Window | Package Manager 使用包管理器来安装它们。

目前在 XR 产品上的开发体验有点好坏参半。这涉及到使用一些顶级的硬件和软件,这意味着会有不断的变更、重新设计、故障、补丁、错误、兼容性问题、性能问题、渲染伪影、平台之间功能不匹配等问题。所有这些问题都旨在减缓我们的进度,这使得在 XR 领域获得竞争优势变得格外困难。从积极的一面来看,几乎每个人都在遇到同样的问题,因此它们得到了开发者的很多关注,这使得它们更容易开发。随着每一次更新的进行,我们都会学到经验教训,API 会得到清理,新的功能、工具和优化也会被提供。

性能问题限制了 XR 产品的成功,可能比非 XR 项目更为严重,因为当前媒体的状态。让我们看看其中的一些性能问题:

  • 首先,我们的用户将花费大量金钱购买 VR 头戴式显示器和传感器设备或 AR 兼容的硬件。这两个平台都非常资源密集,需要类似昂贵的图形硬件来支持它们。这通常导致用户期望与典型游戏相比的质量水平要高得多,这样投资才感觉值得。换句话说,这就使得由于用户需要投入的金钱,不良的用户体验变得可以理解地难以原谅。

  • 其次,对于 VR 项目来说可能比 AR 项目更为重要,糟糕的应用程序性能可能导致严重的物理用户不适,迅速将最坚定的支持者转变为反对者。特别是,如果 XR 应用程序的帧率不足,玩家感受到的运动(例如,通过旋转头部)与他们看到的内容之间将存在差异(我们将在稍后详细了解这一点)。这导致常见的运动病问题,在某些情况下,可能持续数小时

  • 第三,XR 平台的主要吸引力是其沉浸感,而帧率下降、闪烁或任何迫使用户取下头戴式设备或重新启动应用程序的应用程序故障都会更快地打破这种沉浸感。

最终,我们必须准备好在早期对 XR 应用程序进行性能分析,以确保我们没有超出我们的运行时间预算,因为其背后的技术和媒体复杂且资源密集,这将使预算变得紧张。

用户舒适度

与典型的游戏和应用不同,VR 应用需要将用户舒适度作为一个指标来优化自己。不幸的是,眩晕、晕动症、眼睛疲劳、头痛,甚至由于平衡失调而导致的身体伤害,对于早期的 VR 采用者来说都太过常见了,而且责任在我们身上,我们需要限制这些负面影响。本质上,内容对用户舒适度的重要性与硬件一样,如果我们是为这个媒介构建,我们就必须认真对待这个问题。

并非每个人都会遇到这些问题,而且有一些幸运的人从未经历过这些问题;然而,绝大多数用户都曾在某个时候报告过这些问题。此外,仅仅因为我们的游戏在测试时没有触发这些问题,并不意味着它们不会在其他人身上触发。事实上,由于熟悉,我们可能会成为我们游戏的最有偏见的测试对象。不知不觉中,我们可能会开始预测我们应用产生的最令人恶心行为,这使得与一个新用户经历相同情况相比,测试变得不公平。这不幸地进一步提高了 VR 应用开发的成本,因为如果我们想弄清楚我们的体验是否会引起不适,就需要对不同的无偏见个体进行大量测试,这可能每次我们在影响运动和帧率的重大更改时都需要。

用户可以采取一些措施来提高他们的 VR 舒适度,例如从短时间会话开始,逐渐增加时间以获得平衡练习,并训练大脑预期不匹配的运动。一个更极端的选择是在之前服用晕动症药物或喝一点姜茶来稳定胃部。然而,如果我们承诺只需几场晕动症就会开始变得有趣,我们几乎无法说服用户尝试我们的应用。

用户在 VR 中可以体验到三种主要的不适类型:

  • 晕动症:第一个问题是由于晕动症引起的恶心,通常发生在用户的眼睛认为的地平线与他们的其他感官告诉大脑的信息不一致时,例如内耳的平衡感。

  • 眼睛疲劳:第二个问题是眼睛疲劳,这源于用户盯着离眼睛几英寸远的屏幕,这往往会导致长时间使用后眼睛疲劳,并最终引起头痛。

  • 迷失方向:最后,迷失方向通常是因为 VR 用户有时站在一个封闭的空间内,所以如果一款游戏包含任何基于加速度的运动,用户会本能地试图通过调整平衡来抵消这种加速度,这可能导致迷失方向、跌倒,如果我们在确保用户体验平滑和可预测的运动方面不够小心,用户可能会受伤。

注意,术语加速度是故意这样使用的,因为它是一个矢量,这意味着它既有大小也有方向。任何类型的加速度都可能引起迷失方向,这包括不仅向前、向后和侧向加速,还包括旋转形式的加速度(转身)、坠落、跳跃等等。

VR 应用可能面临的另一个潜在问题是引发癫痫发作的可能性。VR 能够将图像以近距离投射到用户的眼睛中,这带来了一些风险,如果我们渲染行为崩溃并开始闪烁,我们可能会无意中触发易感用户的癫痫发作。这些都是我们在开发过程中需要牢记的事情,需要尽早测试和修复。

在 VR 应用中,可能最重要的性能指标是拥有高帧率(帧每秒FPS),最好是 90 FPS 或更高,因为这将产生平滑的观看体验,因为用户头部运动和世界运动之间的脱节将非常小。任何长时间的帧率下降或帧率值持续低于这个值都可能给我们的用户带来很多问题,因此,我们的应用程序必须始终表现良好。此外,我们还应该非常小心地控制用户的视角。我们应该避免自己改变 HMD 的视野(让用户决定他们面向的方向),在长时间内产生加速度,或者引起不受控制的全球旋转和地平线运动,因为这些极有可能触发用户的运动病和平衡问题。

一个不容置疑的严格规则是,我们绝对不应该在我们的产品最终构建中对 HMD(头戴式显示器)的位置跟踪应用任何类型的增益、乘数效应或加速效应。这样做是为了测试是可以的,但如果真实用户将头部向侧面移动两英寸,那么它应该感觉就像在应用程序内部移动了相同的相对距离,并且应该在他们的头部停止时立即停止。否则,不仅会导致玩家感觉头部应该所在的位置和实际位置之间的脱节,而且如果摄像头相对于玩家的朝向和颈部角度发生偏移,还可能引起一些严重的不适。

对于玩家角色的动作,可以使用加速度,但应该在用户开始快速自我调整之前非常短促和迅速。最明智的做法是坚持使用基于恒定速度和/或瞬移的运动。

在赛车游戏中放置倾斜转弯似乎大大提高了用户的舒适度,因为用户会自然地倾斜头部并调整平衡以匹配转弯。

所有之前的规则同样适用于 360 度视频内容,就像它们适用于 VR 游戏一样。坦白说,市场上已经发布了大量没有考虑到上述要点的 360 度视频——它们包含太多颠簸的动作、缺乏相机稳定、手动视口旋转等问题。这些技巧通常被用来确保用户面向我们希望的方向;然而,我们必须付出更多努力来避免引起恶心感的行为,而不是通过黑客手段。人类天生对移动的事物非常好奇。如果他们在眼角注意到某个东西在移动,那么他们很可能会转身面对它。这可以非常有效地用来在用户观看视频时保持他们面向我们希望的方向。

在生成 VR 内容时,懒惰并不是一个好的选择。不要只是将 360 度相机放在一辆越野赛车顶部,并在视频中强行加入意外的相机旋转来保持动作在中心。动作需要平滑且可预测。在制作过程中,我们需要不断考虑我们期望用户看向哪里,以便正确捕捉动作镜头。

幸运的是,对于 360 度视频格式,似乎行业标准的帧率,如 24 FPS 或 29.97 FPS,对用户舒适度没有灾难性的影响,但请注意,这个帧率仅适用于视频播放。我们的渲染 FPS 是一个单独的 FPS 值,决定了位置头跟踪的平滑度。渲染 FPS 必须始终非常高,以避免不适(理想情况下,90 FPS)。

当构建 VR 应用时,会出现其他问题——不同的 HMD 和控制器支持不同的输入和行为,这使得在 VR 平台之间实现功能一致性变得困难。如果我们尝试将 2D 和 3D 内容合并在一起,可能会发生一个称为立体冲突的问题,其中 2D 对象似乎在 3D 对象深处渲染,因为眼睛无法正确区分距离。这通常是 VR 应用程序用户界面和 360 度视频播放的一个大问题,它们往往是一系列叠加在 3D 背景上的平面面板。立体冲突通常不会导致恶心,但它可能会造成额外的眼部疲劳。

虽然不适感在 AR 平台上的影响并不那么明显,但仍然很重要,不要忽视它。由于 AR 应用倾向于消耗大量资源,低帧率的应用可能会引起一些不适。这尤其适用于 AR 应用将对象叠加到相机图像上(这占大多数),其中背景相机图像和叠加其上的物体之间可能会出现帧率不匹配。我们应该尝试同步这些帧率,以限制这种不匹配。

XR 中的性能提升

关于行业和 XR 开发的讨论就到这里。在下一节中,我们将介绍一些可以应用于 XR 项目的性能提升方法,例如选择不同类型的立体渲染算法,以及如何将抗锯齿和其他效果应用于 VR 游戏。

厨房水槽

由于 AR 和 VR 应用是使用与任何其他 Unity 游戏相同的引擎、子系统、资产、工具和实用程序构建的,因此本书中提到的几乎所有其他性能提升都可以以某种方式帮助 VR 和 AR 应用,我们应该在深入研究 XR 特定增强之前尝试它们所有。这令人欣慰,因为我们可以应用许多潜在的性能提升。缺点是我们可能需要应用许多它们才能达到我们应用所需的性能水平。

VR 应用性能的最大威胁是 GPU 填充率,这已经是任何其他游戏中更可能的瓶颈之一,但对于 VR 来说,影响更大,因为我们始终试图将高分辨率图像渲染到更大的帧缓冲区(因为我们实际上是在为每个眼睛渲染场景两次)。AR 应用通常会在 CPU 和 GPU 上发现极端消耗,因为 AR 平台大量使用 GPU 的并行管道来解决对象的空间局部性,执行图像识别等任务,以及需要大量的绘制调用来支持这些活动。

当然,某些性能提升技术在 XR 中可能不会特别有效。在 VR 应用中实施遮挡剔除可能很困难,因为用户可以查看场景中的物体下方、周围,有时甚至能透过物体(尽管它仍然可能带来巨大的好处)。与此同时,AR 应用通常在可触及的距离渲染物体;LOD 增强——即使用更简单的网格为远处的物体建模——可能设置起来相当没有意义。

我们在开始实施之前必须运用更好的判断力来确定一项性能优化技术是否值得实施,因为其中许多都需要花费大量时间来实施和支持。

单次遍历与多次遍历立体渲染

对于 VR 应用程序,Unity 提供了三种渲染模式:多通道渲染、单通道渲染和单通道实例渲染。这可以在“编辑 | 项目设置 | 玩家 | XR 设置 | 立体渲染方法”下进行配置(请注意,必须启用“支持虚拟现实”复选框才能显示此选项):

多通道渲染会将场景渲染成两个不同的图像,这些图像将分别显示给每只眼睛。单通道立体渲染将这两个图像合并成一个双宽度的渲染纹理,其中只向每只眼睛显示相关的一半。

注意,XR 设置仅适用于旧系统。如果您安装了新的实验性XR 管理包,您可以通过“编辑 | 项目设置 | XR 插件管理”找到渲染模式。

多通道立体渲染是默认情况。单通道渲染的优势在于它为主线程中的 CPU 工作提供了显著的节省(通过减少绘制调用设置)以及在 GPU 上,因为需要发生的纹理交换更少。当然,GPU 仍然需要努力渲染对象,因为每个对象仍然从两个不同的视角渲染两次(这里没有免费的午餐)。缺点是,这种效果目前只能在使用 OpenGL ES3.0 或更高版本时使用,因此并非所有平台都可用。

此外,它对渲染管线的影响需要额外的关注和努力,尤其是在任何使用屏幕空间效果的着色器(仅使用已绘制到帧缓冲区中的数据的特效)周围。启用单通道立体渲染后,着色器代码不能再对传入的屏幕空间信息做出相同的假设。以下图像显示了多通道立体渲染单通道立体渲染之间屏幕空间坐标的变化:

着色器总是被告知相对于整个输出渲染纹理的屏幕空间坐标,而不是它感兴趣的局部部分——例如,我们通常期望x值为0.5对应于屏幕的水平中点,这在使用多通道立体渲染时是这种情况;然而,如果我们使用单通道立体渲染,那么x值为0.5将对应于双眼渲染之间的中点(左眼的右侧或右眼的左侧)。

Unity 为着色器提供了有用的屏幕空间转换辅助方法,可以在docs.unity3d.com/Manual/SinglePassStereoRendering.html找到。

另一个需要担心的问题是后期处理效果。在 VR 场景中,我们实际上总是为任何应用的后期处理效果支付双倍的成本,因为它需要为每只眼睛评估一次。单次渲染立体渲染可以减少设置效果所需的绘制调用,但我们不能盲目地将后期处理效果同时应用于两张图像。因此,后期处理效果着色器也必须进行调整,以确保它们渲染到正确的输出渲染纹理的一半。如果不这样做,后期处理效果将被拉伸到两只眼睛上两次,对于如镜头光晕等效果可能会显得非常奇怪。

单次渲染实例化(也称为立体实例化),另一方面,是一种实验性的渲染模式,它相对于标准单次渲染有一些优势。它不是通过为左右眼渲染相同对象来加倍绘制调用,而是大量使用GPU 实例化。简而言之,GPU 实例化允许 Unity 向 GPU 发出单个绘制调用,但要求网格必须在两个不同的位置绘制。因此,单次渲染实例化可以与 CPU 性能相比提供显著的改进;然而,自定义着色器需要为 GPU 实例化做好准备:这涉及到向着色器添加一个位置参数(以便 GPU 知道如何移动网格)和几个 Unity 实用函数。

在自定义着色器上启用 GPU 实例化是一项艰巨的任务,除非你已经对 Unity 着色语言有一些经验,否则不建议这样做。如果你有经验,那么开始的地方是docs.unity3d.com/Manual/SinglePassInstancing.html

单次渲染(包括传统和实例化)功能并不适用于所有平台。我们预计它最终将推广到更多平台,但对于支持该功能的平台,我们需要对我们的屏幕空间着色器进行一些性能分析和合理的合理性检查,以确保我们通过启用此选项获得积极的收益。

应用抗锯齿

应用抗锯齿更多的是一种需求,而不是性能提升。抗锯齿显著提高了 XR 项目的保真度,因为对象将更好地融合,看起来更少像素化,从而提高沉浸感,这可能会消耗大量的填充率。我们应该尽早启用此功能,并假设它始终存在,只在绝对最后关头禁用它,以尝试达到我们的性能目标。

使用前向渲染

延迟渲染的优势在于能够以最少的绘制调用解决许多光源。不幸的是,如果我们遵循前面的建议并应用抗锯齿效果,那么在采用延迟渲染时,这必须作为一个后处理屏幕空间着色器来完成。与在正向渲染中作为多采样效果应用相同技术相比,这可能会造成相当大的性能损失,从而使得正向渲染成为两种选项中性能更优的一种。

在 VR 中应用图像效果

法线贴图应用的效果在 VR 中很容易崩溃,纹理看起来是画在表面上的,而不是给人一种深度错觉。法线贴图通常在非常斜(浅)的观察角度下迅速崩溃,这与典型游戏中的情况不太常见;然而,在 VR 中,由于大多数头戴式显示器允许用户通过位置跟踪在 3D 空间中移动他们的头部(尽管并非所有设备都这样做),他们很快就会找到任何靠近摄像机的物体效果崩溃的位置。已知法线贴图可以改善 VR 中高多边形对象的质量,但很少为低多边形对象提供好处,因此我们应该进行一些测试,以确保任何视觉改进都值得在内存带宽上的成本。

最终,我们不能依赖法线贴图来为低多边形对象提供快速且经济的图形保真度提升,这些对象是我们从非 VR 场景中可能期望得到的。因此,需要进行测试以确定这种错觉是否按预期工作。应使用位移贴图、细分或视差贴图来创建更逼真的深度外观。不幸的是,所有这些技术都比典型的法线贴图成本更高,但这是我们在 VR 中实现良好图形质量必须承受的负担。

其他后处理效果,如景深、模糊和镜头光晕,在典型的 3D 游戏中看起来很好,但通常不是我们在现实世界中看到的效果,在 VR 中(至少在眼动追踪支持可用之前)会显得格格不入,因此应该一般避免。

反面裁剪

反面裁剪(从永远不会可见的对象中移除面)对于 VR 和 AR 项目来说可能很棘手,因为玩家的观察角度可能来自任何方向。如果我们想避免破坏沉浸感的角度,靠近摄像机的资产应该是完全封闭的形状。我们还应该仔细考虑对远程对象应用反面裁剪,尤其是如果用户通过传送移动,因为这可能很难完全限制用户的位置。确保您测试游戏世界的边界体积,以确保用户无法逃脱。

空间化音频

音频行业正充满对 VR(或更准确地说,是终于找到了良好用途的旧技术)的新技术,以空间音频的形式呈现音频体验。这些格式的音频数据不再代表特定通道的音频数据,而是包含某些音频谐波数据,这些数据在运行时合并以创建更逼真的音频体验,这取决于当前的相机视口,尤其是垂直方向。前一句话中的关键词是运行时,这意味着这种效果与它相关的连续非平凡成本。这些技术将需要 CPU 活动,但也可能使用 GPU 加速来生成它们的效果,因此如果我们在使用空间音频时遇到性能问题,我们应该双重检查这两个设备的行为。

避免相机物理碰撞

在 VR 和 AR 中,用户可以穿过物体移动相机,这可能会破坏他们的沉浸感。虽然添加物理碰撞器到这些表面以防止相机穿过它们可能很有吸引力,但这将在 VR 中导致方向混乱,因为相机不会与用户的移动同步。这也可能破坏 AR 应用的定位跟踪校准。更好的方法是允许用户看到物体内部,或者保持相机和这些表面之间的安全缓冲区。如果我们一开始就不允许玩家太靠近它们,那么就没有将头部穿过墙壁的风险。

由于减少了碰撞器的数量,这将节省性能,但应更多地将其视为一个生活质量问题。我们不应该过于担心通过这种方式冒险破坏沉浸感,因为研究表明,一旦用户意识到他们可以这样做,他们往往会避免看向物体。当这种情况首次发生时,他们可能会经历片刻的困惑或欢笑,但幸运的是,人们往往希望留在我们创造的沉浸式体验中,并且会倾向于避免将头部穿过墙壁。然而,能够这样做提供了游戏玩法上的优势,即能够透过墙壁观察敌人即将从哪里出现,因此我们应该考虑到这一点来开发我们的场景。

避免使用欧拉角

避免在任意类型的方向行为中使用欧拉角。四元数被设计得在表示角度方面要好得多(唯一的缺点是它们更抽象,在调试时更难可视化),并且当有变化时,它们可以保持准确性,同时避免可怕的陀螺仪锁定。在计算中使用欧拉角可能会导致大量旋转变化后的不准确,这在 VR 和 AR 中是极其可能的,因为用户的视点会每秒以微小的量多次改变。

欧拉角可能会出现陀螺仪锁定的问题。由于欧拉角通过三个轴来表示方向,当其中一个轴旋转 90 度时,会出现重叠,我们可能会意外地将它们锁定在一起,变得在数学上无法分离,并导致未来的方向变化同时影响两个轴。当然,人类可以想出如何旋转对象来解决这个问题,但陀螺仪锁定是一个纯粹数学上的问题。经典的例子是战斗机中的方向气泡。飞行员永远不会遇到陀螺仪锁定的问题,但他们的抬头显示仪中的方向仪器可能会因为陀螺仪锁定而变得不准确。四元数通过包含一个第四个值来解决这个问题,这个值有效地允许重叠的轴仍然可以相互区分。

练习节制

VR 应用的性能目标非常难以达到。因此,当我们试图将过多的质量塞入我们的应用中,超出了当前一代 XR 设备和典型用户硬件的容忍度时,认识到这一点非常重要。最后的手段总是从场景中剔除对象,直到我们达到性能目标。对于 XR 应用,我们应该比非 XR 应用更愿意这样做,因为性能不佳的成本往往远超过高质量带来的收益。如果已经明显看出渲染预算已经耗尽,我们就应该避免在场景中添加更多细节。这在沉浸式 VR 内容中可能很难承认,因为我们希望创造尽可能多的引人入胜的沉浸感,但直到技术赶上我们的野心,我们仍需要保持节俭。

跟踪最新发展

Unity 提供了一系列包含 VR 设计和优化技巧的有用文章和教程,随着媒体和市场的成熟以及新技术的发现,这些内容可能会得到更新。这个列表可能比这本书更新得更快,所以请时不时地查看它们,以获取最新的技巧。通常,相关的文章和教程可以在 learn.unity.com 找到。

我们还应该关注 Unity 博客,以确保我们不会错过关于 XR API 变更、性能增强和性能建议的重要信息。

摘要

希望这份简短的指南能帮助您提升您的 XR 应用性能。令人欣慰的消息是,您有许多性能优化选项可供选择,因为 Unity XR 应用是基于我们在整本书中一直在探索的相同底层平台构建的。不那么令人欣慰的是,我们可能必须测试和实施所有这些选项,才有可能达到我们的质量目标。我们可以预期硬件会随着时间的推移变得更加强大,价格会下降,采用率也会提高;然而,在此之前,如果我们想在科技世界的最新热潮中竞争,我们就必须全力以赴。

在下一章中,我们将深入探讨 Unity 的底层引擎,以及它所构建的各种框架、层和语言。本质上,我们将更深入地查看我们的脚本代码,并研究一些方法来全面提升我们的 CPU 和内存管理。

第八章:精通内存管理

内存效率是性能优化的一个重要元素。对于范围有限的游戏,如爱好项目和原型,可以忽略内存管理。这些游戏可能会浪费大量资源,并可能发生内存泄漏,但如果仅限于朋友和同事之间,这不会成为问题。然而,任何我们想要专业发布的,都需要认真对待这个问题。不必要的内存分配会导致垃圾回收过多(消耗宝贵的 CPU 时间)和内存泄漏,这会导致崩溃。在现代游戏发布中,这些情况都是不可接受的。

使用 Unity 高效利用内存需要深入了解底层 Unity 引擎、Mono 平台和 C#语言。此外,如果我们正在使用新的 IL2CPP 脚本后端,那么熟悉其内部工作原理将是明智的。这对一些开发者来说可能有点令人畏惧,因为许多人选择 Unity3D 作为他们的游戏开发解决方案,主要是为了避免来自引擎开发和内存管理的底层工作。我们更愿意关注与游戏实现、关卡设计和艺术资源管理相关的更高层次的问题,但不幸的是,现代计算机系统是复杂的工具,长期忽视底层问题可能会导致灾难。

理解内存分配和 C#语言特性正在发生什么,它们如何与 Mono 平台交互,以及 Mono 如何与底层 Unity 引擎交互,对于编写高质量、高效的脚本代码至关重要。因此,在本章中,你将了解底层 Unity 引擎的所有细节:Mono 平台、C#语言、中间语言到 C++(IL2CPP)和.NET 框架。

幸运的是,要有效地使用 C#语言,并不需要成为绝对的语言大师。本章将把这些复杂主题简化为更易于理解的形式,并分为以下主题:

  • Mono 平台概述:

    • 原生和托管内存域

    • 垃圾回收

    • 内存碎片化

  • 使用 IL2CPP 构建项目

  • 如何分析内存问题

  • 实施各种与内存相关的性能提升:

    • 最小化垃圾回收

    • 正确使用值类型和引用类型

    • 负责任地使用字符串

    • 与 Unity 引擎相关的众多潜在提升

    • 对象和 Prefab 池化

Mono 平台

Mono 是一种神奇的风味酱,被混合到 Unity 的配方中,赋予了它许多跨平台的能力。Mono 是一个开源项目,它基于微软 .NET 框架的 API、规范和工具构建了自己的平台库。本质上,它是对 .NET 库的开源重制,几乎无需访问原始源代码,并且与微软的原始库完全兼容。

Mono 项目的目标是提供一个框架,通过这个框架,用通用编程语言编写的代码可以在许多不同的硬件平台上运行,包括 Linux、macOS、Windows、ARM、PowerPC 等。Mono 还支持许多不同的编程语言。任何可以编译成 .NET 的 通用中间语言CIL)的语言都足以与 Mono 平台集成。这包括 C# 本身,还包括 F#、Java、Visual Basic .NET、pythonnet 和 IronPython 等几种其他语言。

关于 Unity 引擎的一个常见误解是它建立在 Mono 平台之上。这是不正确的,因为基于 Mono 的层不处理许多重要的游戏任务,如音频、渲染、物理和跟踪时间。Unity Technologies 为了速度构建了一个本地的 C++ 后端,并允许用户通过 Mono 作为脚本接口来控制这个游戏引擎。因此,Mono 只是底层 Unity 引擎的一个成分。这与许多其他游戏引擎类似,它们在底层运行 C++,处理渲染、动画和资源管理等重要任务,同时为游戏逻辑的实现提供高级脚本语言。因此,Unity Technologies 选择 Mono 平台来提供这一功能。

本地代码是对专门为特定平台编写的代码的通俗说法。例如,在 Windows 中编写创建窗口对象或与网络子系统接口的代码,与为 macOS、Unix、PlayStation 4、Xbox One 等执行任务的代码完全不同。

脚本语言通常通过自动垃圾回收来抽象复杂的内存管理,并提供各种安全特性,从而简化编程过程,但这也带来了运行时的开销。一些脚本语言也可以在运行时进行解释,这意味着它们在执行前不需要编译。原始指令在运行时动态转换为机器代码,并在读取时立即执行;当然,这通常会使代码相对较慢。最后一个特性,也可能是最重要的特性,是它们允许编程命令具有更简单的语法。这通常极大地改善了开发工作流程,因为即使是没有太多使用 C++等语言经验的团队成员也能为代码库做出贡献。这使他们能够在牺牲一定程度的控制和运行时执行速度的情况下,以更简单的格式实现游戏逻辑等功能。

注意,这类语言通常被称为管理语言,它们具有管理代码的特点。技术上,这是一个由微软创造的术语,指的是必须在他们的公共语言运行时CLR)环境中运行的任何源代码,与通过目标操作系统本地编译和运行的代码相对。

然而,由于 CLR 与其他具有类似设计的运行时环境的语言(如 Java)的普遍性和共同特性,术语管理已经被篡改。它通常用来指代任何依赖于其自身运行时环境,并且可能包含或不包含自动垃圾回收的语言或代码。在本章的其余部分,我们将采用这个定义,并使用术语管理来指代既依赖于单独的运行时环境来执行,又受到自动垃圾回收监控的代码。

管理语言的运行时性能成本始终大于等效的本地代码,但每年都在逐渐降低。这部分是由于工具和运行时环境的逐步优化,部分是由于平均设备的计算能力逐渐增强。然而,使用管理语言的主要争议点仍然是它们的自动内存管理。手动管理内存可能是一项复杂的任务,可能需要多年的困难调试才能熟练掌握,但许多开发者认为,管理语言以过于不可预测的方式解决这个问题,风险太大,可能会影响产品质量。这样的开发者可能会声称,管理代码永远不会达到本地代码相同的性能水平,因此用它们构建高性能应用是鲁莽的。

在一定程度上这是正确的,因为受管理语言不可避免地会带来运行时开销,我们失去了对运行时内存分配的部分控制。这对于高性能服务器架构来说可能是一个致命的问题;然而,对于游戏开发来说,这变成了一种权衡,因为并非所有资源的使用都会必然导致瓶颈,而且最好的游戏也不一定是那些充分利用每个字节的潜在能力的游戏。例如,想象一个用户界面通过原生代码在 30 毫秒内刷新,而通过受管理代码在 60 微秒内刷新,因为额外的 100% 开销(一个极端的例子)。受管理代码版本仍然足够快,以至于用户永远无法察觉到差异,那么使用受管理代码来完成这样的任务真的有伤害吗?

实际上,至少对于游戏开发来说,使用受管理语言通常意味着与原生代码开发者相比,开发者有一套独特的担忧需要关注。因此,选择使用受管理语言进行游戏开发部分是关于偏好,部分是关于控制与开发速度的妥协。

让我们回顾一下我们在前面的章节中提到但并未详细阐述的话题:Unity 引擎中内存域的概念。

内存域

Unity 引擎内的内存空间可以基本上分为三个不同的内存域。每个域存储不同类型的数据,并负责非常不同的任务。让我们逐一看看它们:

  • 第一个内存域——受管理域——应该非常熟悉。这个域是 Mono 平台工作的地方,我们编写的任何 MonoBehaviour 脚本和自定义 C# 类将在运行时实例化,因此我们将通过我们编写的任何 C# 代码非常明确地与这个域交互。它被称为受管理域,因为这个内存空间是由 垃圾回收器GC)自动管理的。

  • 第二个域——原生域——更为微妙,因为我们只是间接地与之交互。Unity 有一个底层的原生代码基础,它用 C++ 编写,并根据目标平台的不同编译成我们的应用程序。这个域负责为诸如资产数据(例如,纹理、音频文件和网格)以及各种子系统(如渲染管线、物理系统和用户输入系统)分配内部内存空间。最后,它还包括重要的游戏玩法对象(如 GameObject 和组件)的部分原生表示,以便它们可以与这些内部系统交互。这就是许多内置的 Unity 类存储数据的地方,例如 transformRigidbody 组件。

  • 第三和最后一个内存域是外部库,例如 DirectX 和 OpenGL 库,以及我们项目中所包含的任何自定义库和插件。从我们的 C#代码中引用这些库将导致类似的内存上下文切换和后续成本。

管理域还包括对存储在本地域中的相同对象表示的包装器。因此,当我们与如transform之类的组件交互时,大多数指令都会要求 Unity 深入到其本地代码中,在那里生成结果,然后将结果复制回管理域供我们使用。这就是本地-管理桥在管理域和本地域之间的由来,这在之前的章节中简要提到过。当两个域都有相同实体的自己的表示时,跨越它们之间的桥梁需要内存上下文切换,这可能会对我们的游戏造成相当大的性能影响。显然,由于涉及的开销,跨越这座桥梁的次数应该尽可能减少。我们在第二章“脚本策略”中介绍了几种处理这种问题的技术。

在大多数现代操作系统中,内存运行时空间被分为两个类别。

堆栈

堆栈是内存中的一个特殊预留空间,用于存储小而短暂的数据值,这些数据值一旦超出作用域就会被自动释放,这就是为什么它被称为堆栈。它实际上就像一个堆栈数据结构,从顶部推入和弹出数据。堆栈的分配符合以下属性:

  • 堆栈包含我们声明的任何局部变量,并处理函数的加载和卸载,当函数被调用时。这些函数调用通过所谓的调用栈进行扩展和收缩。当调用栈完成当前函数的处理时,它会跳回到调用栈上的前一个点,并从那里继续执行。

  • 上一次内存分配的起始位置总是已知的,因此没有必要执行任何清理操作,因为任何新的分配都可以简单地覆盖旧数据。因此,堆栈相对快速且高效。

  • 总的堆栈大小通常非常小,通常在 MB 的数量级。如果分配的空间超过了堆栈所能支持的范围,就可能导致堆栈溢出。这种情况可能发生在异常大的调用栈(例如,无限循环)或拥有大量局部变量时,但在大多数情况下,尽管堆栈大小相对较小,但造成堆栈溢出通常不是一个大问题。

堆代表所有剩余的内存空间,并且它被用于绝大多数的内存分配。

  • 由于我们希望大部分分配的内存能够比当前函数调用持久,所以我们不能在栈上分配它,因为当当前函数结束时,它会被覆盖。因此,相反,每当一个数据类型太大而无法适应栈或者必须在声明它的函数外部持久时,它就会在堆上分配。

  • 栈和堆在物理上没有区别;它们都是包含在 RAM 中的字节数据内存空间,这是操作系统为我们请求并预留的。唯一的不同在于它们的使用时间、地点和方式。

在原生代码中,例如用 C++等语言编写的代码,这些内存分配是手动处理的,我们负责确保所有分配的内存在我们不再需要时都得到适当的和明确的释放。如果没有正确处理,我们很容易无意中引入内存泄漏,因为我们可能会不断地从 RAM 中分配更多的内存空间,而这些内存永远不会被清理,直到没有更多的空间可以分配,应用程序崩溃。

同时,在托管语言中,这个过程通过 GC(垃圾回收)自动化。在我们 Unity 应用的初始化过程中,Mono 平台将从操作系统请求一块内存,并使用它来生成我们的 C#代码可以使用的堆内存空间(通常称为托管堆)。这个堆空间最初相当小,小于 1 MB,但随着我们的脚本代码需要新的内存块时,它会增长。如果 Unity 确定它不再需要,这个空间也可以通过释放回操作系统来缩小。

垃圾回收

GC 有一个重要的任务,就是确保我们不会使用比我们需要的更多的托管堆内存,并且不再需要的内存会被自动释放。例如,如果我们创建GameObject然后后来销毁它,GC 会标记GameObject使用的内存空间,以便稍后进行释放。这不是一个立即的过程,因为 GC 只有在必要时才会释放内存。

当发起一个新的内存请求,并且托管堆中有足够的空闲空间来满足请求时,GC 简单地分配新的空间并将其交给调用者。然而,如果托管堆没有足够的空间,GC 将需要扫描所有现有的内存分配,查找任何不再被使用的部分,并首先清理它们。它只有在最后手段的情况下才会扩展当前的堆空间。

Unity 使用的 Mono 版本中的 GC 是一种跟踪 GC,它使用标记-清除策略。这个算法分为两个阶段:每个分配的对象都通过一个额外的位来跟踪。这个位标记对象是否被标记。这些标志最初设置为false,表示它尚未被标记。

当收集过程开始时,它会通过将它们的标志设置为true来标记所有程序仍然可以到达的对象。这些可到达的对象可以是直接引用,例如堆栈上的静态或局部变量,或者是通过其他直接或间接可访问对象的字段(成员变量)进行间接引用。本质上,它是收集一组我们应用仍然可以引用的对象。所有不再可引用的东西将对我们应用来说实际上是不可见的,并且可以被 GC 回收。

第二阶段涉及遍历这个引用目录(GC 将在整个应用生命周期中跟踪这个目录),并根据其标记状态确定是否应该释放。如果对象被标记,那么它仍然被其他东西引用,因此 GC 不会对其进行操作。然而,如果没有标记,那么它就是释放的候选对象。在这个阶段,所有标记的对象都会被跳过,但在下一轮垃圾收集扫描的第一阶段之前,会将其标志重新设置为false

本质上,GC 维护内存中所有对象的列表,而我们的应用维护一个单独的列表,只包含其中的一部分。每当我们的应用完成一个对象时,它只是简单地忘记它的存在,将其从列表中删除。因此,可以安全释放的对象列表将是 GC 的列表和我们的应用列表之间的差异。

第二阶段结束后,所有未标记的对象都会被释放到空闲空间,然后重新访问创建对象的初始请求。如果 GC 已经为对象释放了足够的空间,那么它将在新释放的空间中分配,并返回给调用者。然而,如果没有足够的空间,那么我们将遇到最后的手段情况,必须通过从操作系统请求来扩展托管堆,此时对象空间最终可以被分配并返回给调用者。

在一个理想的世界里,我们只分配和释放对象,但一次只存在有限数量的对象,堆将保持大致恒定的尺寸,因为总有足够的空间来容纳我们需要的新的对象。然而,一个应用中的所有对象很少按照它们分配的顺序被释放,而且它们在内存中的大小也很少相同。这导致了内存碎片。

内存碎片

当不同大小的对象交替分配和释放,并且大量小对象被释放,随后大量大对象被分配时,就会发生碎片化。

这最好通过一个例子来解释。以下展示了我们在典型堆内存空间中分配和释放内存的四个步骤:

图片

内存分配如下:

  1. 我们从一个空的堆空间开始

  2. 然后我们在堆上分配了四个对象,ABCD,每个对象大小为 64 字节

  3. 在稍后的时间,我们释放了两个对象,AC,释放了 128 字节

  4. 然后我们尝试分配一个大小为 128 字节的新的对象

释放对象 AC 从技术上讲释放了 128 字节的空间,但由于这些对象在内存中不是连续的(相邻的邻居),我们无法分配比这两个单独空间都大的对象。新的内存分配必须始终在内存中连续;因此,新对象必须在托管堆中下一个可用的连续 128 字节空间中分配。我们现在在内存空间中有两个 64 字节的空隙,除非我们分配大小为 64 字节或更小的对象,否则将永远不会被重用。

在长时间内,我们的堆内存可能会因为不同大小的对象释放后留下的更多、更小的空隙而变得布满空隙,然后系统稍后尝试在可以容纳新对象的最小可用空间内分配新对象,留下一些难以填充的小余量。如果没有自动清理这种碎片化的背景技术,这种效果会在任何内存空间中发生——RAM、堆空间,甚至硬盘——它们只是更大、更慢、更持久的内存存储区域(这就是为什么定期对硬盘进行碎片整理是个好主意)。

内存碎片化导致两个问题:

  • 首先,它有效地减少了长期内新对象的可用内存空间,这取决于分配和释放的频率。这可能导致垃圾回收器必须扩展堆以为新分配腾出空间。

  • 其次,它使得新的分配需要更长的时间来解决,因为找到足够大的新内存空间来容纳对象需要额外的时间。

当在堆中创建新的内存分配时,这变得很重要,因为可用空间的位置变得与可用空间的大小一样重要。无法将对象分割到部分内存位置,因此垃圾回收器必须继续搜索,直到找到足够大的空间,或者整个堆大小必须增加以容纳新对象,这会在它花费大量时间进行彻底搜索之后,再次花费更多时间。

运行时垃圾回收

因此,在最坏的情况下,当我们的游戏请求新的内存分配时,CPU 必须完成以下任务才能最终完成分配:

  1. 确认有足够连续的空间用于新对象。

  2. 如果空间不足,遍历所有已知的直接和间接引用,标记它们连接的所有内容为可到达的

  3. 再次遍历所有这些引用,标记未标记的对象以进行释放

  4. 遍历所有标记的对象,检查释放其中一些是否能为新对象创建足够的连续空间

  5. 如果不是,则从操作系统请求新的内存块以扩展堆空间

  6. 在新分配的块的前端分配新对象,并将其返回给调用者

这对于 CPU 来说可能是一项繁重的工作,尤其是如果这种新的内存分配是一个重要的对象,如粒子效果、新角色进入场景或场景过渡。用户极有可能注意到 GC 在处理这种极端情况时冻结游戏时刻。更糟糕的是,随着分配的堆空间增长,垃圾回收的工作负载扩展得不好,因为扫描几个 MB 的空间将比扫描几个 GB 的空间快得多。

所有这些使得智能控制堆空间变得绝对关键。我们的内存使用策略越懒惰,垃圾回收(GC)的行为就会以几乎指数级的速度变得更糟,因为我们越来越有可能遇到这种最坏的情况。因此,尽管管理语言试图使内存管理问题更容易解决,但管理语言的开发者仍然发现自己对内存消耗的关注程度与本地应用程序的开发者一样,如果不是更多的话。主要区别在于他们试图解决的问题类型。

线程化垃圾回收

GC 在两个独立的线程上运行:主线程和所谓的终结器线程。当 GC 被调用时,它将在主线程上运行,并为未来的释放标记堆内存块。这不会立即发生。由 Mono 控制的终结器线程,在内存最终被释放并可用于重新分配之前,可能会有几秒钟的延迟:

图片

我们可以在分析器窗口中的内存区域(绿色线条,向那 5%的色盲/色觉异常人群道歉)的“总分配”块中观察到这种行为。垃圾回收发生后,总分配值可能需要几秒钟才能下降。由于这种延迟,我们不应依赖于内存一旦被释放就立即可用,因此我们永远不应浪费时间试图挤出我们认为应该可用的每一字节内存。我们必须确保始终有一些类型的缓冲区可用于未来的分配。

被 GC 释放的块有时会在一段时间后归还给操作系统,这将减少堆占用的预留空间,并允许内存为其他事物分配,例如另一个应用程序。然而,这是非常不可预测的,并且取决于目标平台,所以我们不应该依赖它。唯一安全的假设是,一旦内存被分配给 Mono,它就被预留,并且不再对本地域或同一系统上运行的任何其他应用程序可用。

在下一节中,我们将探讨开发过程中的另一个基本元素:代码编译。在代码编译过程中,C#代码将被转换成 CPU 实际执行的指令。令人惊讶的是,有多种执行这种转换的方法;让我们看看如何在这之间进行选择。

代码编译

当我们修改我们的 C#代码时,当我们从我们最喜欢的 IDE(通常是 MonoDevelop 或功能更丰富的 Visual Studio)切换回 Unity 编辑器时,它会被自动编译。然而,C#代码并不是直接转换成机器代码,正如我们预期的那样,如果我们使用像 C++这样的语言,静态编译器会这样做。

相反,代码被转换成一个称为通用中间语言CIL)的中间阶段,它是对本地代码的抽象。这就是.NET 能够支持多种语言的原因——每种语言使用不同的编译器,但它们都被转换成 CIL,所以输出的结果在语言选择上实际上是相同的。CIL 类似于基于其之上的 Java 字节码,CIL 代码本身是完全无用的,因为 CPU 不知道如何运行这种语言中定义的指令。

在运行时,这种中间代码将通过 Mono 的虚拟机VM)运行,这是一个基础设施元素,它允许相同的代码在多个平台上运行而无需更改代码本身。这是.NET CLR 的一个实现。如果我们运行在 iOS 上,我们就运行在基于 iOS 的 VM 基础设施上;如果我们运行在 Linux 上,我们就简单地使用更适合 Linux 的一个。这就是 Unity 允许我们一次编写代码,并在多个平台上神奇地工作的原因。

在 CLR 内部,中间的 CIL 代码实际上会在需要时编译成本地代码。这种即时本地编译可以通过提前编译AOT)或即时编译JIT)编译器来完成。使用哪种编译器将取决于目标平台。这些编译器允许代码段被编译成本地代码,使得平台的架构能够完成编写的指令,而无需我们亲自编写。这两种编译器类型的主要区别在于代码编译的时间。

AOT 编译是代码编译的典型行为,在构建过程中或在某些情况下在应用程序初始化期间早期(AOT)发生。在任何情况下,代码都已预编译,由于始终有机器代码指令可用,因此动态编译不会对运行时造成进一步的成本。

JIT 编译在运行时动态发生,在单独的线程中进行,并在执行前开始(JIT 用于执行)。通常,这种动态编译会导致代码的第一次调用运行得稍微慢一些(或者很多),因为代码必须完成编译才能执行。然而,从那时起,每次执行相同的代码块时,就无需重新编译,指令将通过之前编译的本地代码运行。

软件开发中有一个常见的谚语,那就是 90%的工作量只由 10%的代码完成。这通常意味着即时编译(JIT)在性能上比直接尝试解释 CIL 代码要更有优势。然而,由于 JIT 编译器必须快速编译代码,它无法利用静态 AOT 编译器可以使用的许多优化技术。

并非所有平台都支持 JIT 编译,但在使用 AOT 时,某些脚本功能不可用。Unity 在docs.unity3d.com/Manual/ScriptingRestrictions.html提供了一个完整的限制列表。

几年前,Unity Technologies 面临着一个选择,要么继续支持 Mono 平台,而 Unity 发现越来越难以跟上这个平台的发展,要么实现自己的脚本后端。他们选择了后者,现在多个平台都支持 IL2CPP。

Unity Technologies 关于 IL2CPP 的初始帖子,包括决策背后的原因及其长期效益,可以在blogs.unity3d.com/2014/05/20/the-future-of-scripting-in-unity/找到。

IL2CPP

IL2CPP 是一个脚本后端,旨在将 Mono 的 CIL 输出直接转换为本地 C++代码。这导致了性能的提升,因为应用程序现在将运行本地代码。这最终使 Unity Technologies 对运行时行为有了更多的控制,因为 IL2CPP 提供了自己的 AOT 编译器和 VM,允许对 GC 和编译过程等子系统进行自定义改进。IL2CPP 并不打算完全取代 Mono 平台,但它是一个我们可以启用的额外工具,它改进了 Mono 提供功能的一部分。

注意,IL2CPP 在 iOS 和 WebGL 项目中是自动启用的。对于支持它的其他平台,可以在“编辑”|“项目设置”|“播放器”|“其他设置”|“配置”|“脚本后端”下启用 IL2CPP。

当前支持 IL2CPP 的平台列表可以在docs.unity3d.com/Manual/IL2CPP.html找到。

分析内存

在内存管理方面,我们关注两个问题:我们消耗了多少以及我们多久分配一次新的内存块。让我们分别讨论这些话题。

分析内存消耗

由于我们没有 Unity 引擎的源代码,因此无法直接控制本地域中的操作,我们无法直接添加任何与之交互的代码。然而,我们可以通过各种脚本级函数间接控制它,这些函数作为托管代码和本地代码之间的交互点。实际上,有各种内存分配器可用,它们在内部用于诸如 GameObject、图形对象和 Profiler 等事物,但这些都被隐藏在本地-托管桥接器后面。

然而,我们可以通过 Profiler 窗口的内存区域观察到在这个内存域中分配和保留了多少内存。本地内存分配显示在标记为 Unity 的值下,我们甚至可以使用详细模式和采样当前帧来获取更多信息:

在分解视图的“场景内存”部分,我们可以观察到MonoBehaviour对象总是消耗固定数量的内存,无论它们的成员数据如何。这是对象本地表示消耗的内存。

注意,由于各种调试和编辑器钩子数据的应用,编辑模式下的内存消耗总是与独立版本大相径庭。这进一步增加了避免使用编辑模式进行基准测试和仪器测量的动机。

我们还可以使用Profiler.GetRuntimeMemorySize()方法来获取特定对象的本地内存分配大小。

管理对象表示与它们的本地表示内在相关联。最小化我们的本地内存分配的最佳方式是简单地优化我们的托管内存使用。

我们可以使用 Profiler 窗口的内存区域,在标记为 Mono 的值下验证为托管堆分配和保留了多少内存,如下所示:

我们也可以使用Profiler.GetMonoUsedSize()Profiler.GetMonoHeapSize()方法分别在运行时确定当前使用的和保留的堆空间。

分析内存效率

我们可以用来衡量我们内存管理健康状况的最佳指标是简单地观察 GC 的行为。它做的工作越多,我们产生的浪费就越多,我们的应用程序的性能可能就越差。

我们可以使用 Profiler 窗口的 CPU 使用区域(垃圾回收器复选框)和内存区域(GC 分配复选框)来观察 GC 正在执行的工作量以及它所花费的时间。在某些情况下,这可能是相对直接的,比如我们只分配了一小块临时内存,或者我们刚刚销毁了一个GameObject实例。

然而,对内存效率问题的根本原因分析可能具有挑战性和耗时。当我们观察到 GC 行为的峰值时,这可能意味着在前一帧分配了过多的内存,而在当前帧仅仅分配了更多一些,需要 GC 扫描大量碎片化的内存,确定是否有足够的空间,并决定是否分配一个新的块。它清理的内存可能是在很久以前分配的,我们可能只能在应用程序长时间运行时观察到这些效果,甚至可能在我们场景相对空闲时发生,这时 GC 突然触发没有明显的触发原因。更糟糕的是,Profiler 只能告诉我们过去几秒钟内发生了什么,而且清理了哪些数据可能不会立即明显。

我们必须保持警惕并对我们的应用程序进行严格的测试,在模拟典型游戏会话的同时观察其内存行为,以确保我们没有产生内存泄漏或创建一个 GC(垃圾回收器)在一个帧内需要完成太多工作的情况。

内存管理性能提升

在大多数游戏引擎中,如果我们遇到性能问题,我们可以将低效的托管代码移植到更快的本地代码中。除非我们投入大量资金获取 Unity 源代码,这作为一项单独的许可证提供,并且基于每个案例、每个标题,否则这不是一个选项。我们也可以购买 Unity Pro 许可证,希望使用本地插件,但这样做很少能带来性能提升,因为我们仍然必须跨越本地-托管桥来调用其中的函数调用。本地插件通常用于与为 C#构建的系统库接口。这迫使绝大多数人需要自己尽可能使 C#脚本级代码高效。

考虑到这一点,我们现在应该对 Unity 引擎内部和内存空间有足够的了解,以便检测和分析内存性能问题,并理解和实现对其的改进。因此,让我们来看看我们可以应用的一些性能提升方法。

垃圾回收策略

减少垃圾回收问题的一种策略是在玩家不会注意到的合适时机手动调用 GC。可以通过调用System.GC.Collect()来手动调用垃圾回收。

在加载不同层级之间、游戏暂停时、菜单界面刚打开后、场景切换过程中,或者在玩家不会看到或不在乎性能突然下降的任何游戏中断时,都可能有机会调用垃圾回收。我们甚至可以在运行时使用Profiler.GetMonoUsedSize()Profiler.GetMonoHeapSize()方法来确定是否需要很快地调用垃圾回收。

我们还可以释放一些特定对象。如果问题对象是 Unity 对象包装器之一,例如GameObjectMonoBehaviour组件,那么终结器将首先在本地域中调用Dispose()方法。此时,本地域和管理域消耗的内存将被释放。在某些罕见情况下,如果 Mono 包装器实现了IDisposable接口类(即,从脚本代码中可用Dispose()方法),那么我们实际上可以控制这种行为并强制立即释放内存。

Unity 引擎中有许多不同的对象类型(其中大多数是在 Unity 5 或更高版本中引入的),它们实现了IDisposable接口类,如下所示:NetworkConnectionWWWUnityWebRequestUploadHandlerDownloadHandlerVertexHelperCullingGroupPhotoCaptureVideoCapturePhraseRecognizerGestureRecognizerDictationRecognizerSurfaceObserver等。

这些都是用于拉取可能非常大的数据集的实用类,我们可能希望确保立即销毁它所获取的数据,因为它们通常涉及在本地域中分配几个缓冲区和内存块以完成任务。如果我们长时间保留所有这些内存,那将是一种巨大的空间浪费。因此,通过从脚本代码中调用它们的Dispose()方法,我们可以确保内存缓冲区能够及时且精确地被释放。

所有其他资产对象都提供某种卸载方法来清理任何未使用的资产数据,例如Resources.UnloadUnusedAssets()。实际的资产数据存储在本地域中,因此 GC 实际上并不涉及这里,但基本思想是相同的。它将遍历特定类型的所有资产,检查它们是否不再被引用,如果是,则释放它们。然而,这同样是一个异步过程,我们无法保证确切的释放时间。此方法在场景加载后自动内部调用,但这仍然不能保证立即释放。首选的方法是使用Resources.UnloadAsset(),它将一次卸载一个特定的资产。这种方法通常更快,因为不会花费时间遍历整个资产数据集合来确定哪些是未使用的。

然而,最佳的垃圾回收策略始终是避免;如果我们尽可能少地分配堆内存并尽可能多地控制其使用,那么我们就无需担心 GC 造成频繁且昂贵的性能成本。我们将在本章的剩余部分介绍许多此类策略。

手动 JIT 编译

如果 JIT 编译导致运行时性能损失,请注意,实际上可以通过反射强制在任何时候对方法进行 JIT 编译。反射是 C#语言的一个有用特性,它允许我们的代码库以自省的方式探索自身以获取类型信息、方法、值和元数据。使用反射通常是一个代价很高的过程。它应该在运行时避免使用,或者至少仅在初始化或其他加载时间使用。不这样做很容易导致显著的 CPU 峰值和游戏冻结。

我们可以使用反射手动强制对方法进行 JIT 编译以获取其函数指针:

var method = typeof(MyComponent).GetMethod("MethodName");
if (method != null) {
  method.MethodHandle.GetFunctionPointer();
  Debug.Log("JIT compilation complete!");
}

上述代码仅适用于public方法。获取privateprotected方法可以通过使用BindingFlags实现:

using System.Reflection;
// ...
var method = typeof(MyComponent).GetMethod("MethodName",  
BindingFlags.NonPublic | BindingFlags.Instance);

这种代码仅应在非常具体的方法上运行,其中我们确信 JIT 编译导致了 CPU 峰值。这可以通过重新启动应用程序并分析方法的第一次调用与所有后续调用之间的差异来验证。差异将告诉我们 JIT 编译的开销。

注意,强制在.NET 库中进行 JIT 编译的官方方法是RuntimeHelpers.PrepareMethod(),但在 Unity 当前默认版本的 Mono(Mono 版本 2.6.5)中并未正确实现。自 Unity 2018.1 以来,.NET 4.x 运行时不再被视为实验性;然而,它并不支持所有平台,并且仍然不是建议使用的版本。上述解决方案并不完美,但它仍然是最佳且最一致的方法。

值类型和引用类型

在 Mono 中,我们进行的所有内存分配并不都会通过堆。.NET Framework(以及通过扩展,C#语言,它仅仅实现了.NET 规范)有值类型和引用类型的概念,并且只有后者在 GC 执行其标记-清除算法时需要被标记。由于它们的复杂性、大小或使用方式,引用类型通常(或需要)在内存中持续很长时间。大型数据集以及从class实例实例化的任何类型的对象都是引用类型。这还包括数组(无论它是一个值类型数组还是引用类型数组)、委托、所有类,例如MonoBehaviourGameObject以及我们定义的任何自定义类。

引用类型始终在堆上分配,而值类型可以在栈或堆上分配。例如,boolintfloat 这样的原始数据类型是值类型的例子。这些值通常在栈上分配,但一旦值类型被包含在引用类型中,例如 class 或数组,那么就隐含着它要么太大不适合栈,要么需要比当前作用域存活得更久,因此必须分配在堆上,与它所包含的引用类型一起。

所有这些都可以通过示例最好地解释。以下代码将创建一个作为值类型的整数,它仅在栈上临时存在:

public class TestComponent {
  void TestFunction() {
    int data = 5; // allocated on the stack
    DoSomething(data);
  } // integer is deallocated from the stack here
}

一旦 TestFunction() 方法结束,整数将从栈上释放。这本质上是一个免费操作,因为,如前所述,它不需要进行任何清理;它只是将栈指针移回到调用栈中的上一个内存位置(回到调用 TestFunction()TestComponent 对象的函数)。任何未来的栈分配都会简单地覆盖旧数据。更重要的是,没有进行堆分配来创建数据,因此垃圾收集器不需要跟踪其存在。

然而,如果我们把整数作为 MonoBehaviour 类定义的成员变量创建,那么它现在包含在一个引用类型(class)中,并且必须与它的容器一起在堆上分配:

public class TestComponent : MonoBehaviour {
  private int _data = 5;
  void TestFunction() {
    DoSomething(_data);
  }
}

_data 整数现在是一块额外的数据,它占据了与它所包含的 TestComponent 对象一起在堆上的空间。如果 TestComponent 被销毁,那么整数也会随之被释放,但在此之前不会。

同样,如果我们把整数放入一个普通的 C# 类中,那么引用类型的规则仍然适用,对象将在堆上分配:

public class TestData {
  public int data = 5;
}

public class TestComponent {
  void TestFunction() {
    TestData dataObj = new TestData(); // allocated on the heap
    DoSomething(dataObj.data);
  } // dataObj is not immediately deallocated here, but it will 
    // become a candidate during the next GC sweep
}

因此,在 class 方法中创建临时值类型与将长期值类型作为 class 的成员字段存储之间有很大的区别。在前一种情况下,我们将其存储在栈上,但在后一种情况下,我们将其存储在引用类型中,这意味着它可以在其他地方被引用。例如,想象一下 DoSomething() 在一个成员变量中存储了 dataObj 的引用:

public class TestComponent {
  private TestData _testDataObj;

  void TestFunction() {
    TestData dataObj = new TestData(); // allocated on the heap
    DoSomething(dataObj.data);
  }

  void DoSomething (TestData dataObj) {
    _testDataObj = dataObj; // a new reference created! The referenced 
    // object will now be marked during Mark-and-Sweep
  }
}

在这种情况下,我们无法在 TestFunction() 方法结束时立即释放指向 dataObj 的对象,因为引用该对象的总数将从 2 变为 1。这不是 0,因此垃圾收集器仍然会在标记-清除过程中标记它。在对象不再可达之前,我们需要将 _testDataObj 的值设置为 null 或使其引用其他东西。

注意,值类型必须有一个值,并且永远不能为 null。如果栈分配的值类型被赋值给引用类型,那么数据就会被简单地复制。即使对于值类型的数组也是如此:

public class TestClass {
  private int[] _intArray = new int[1000]; // Reference type 
                                           // full of Value types
  void StoreANumber(int num) {
    _intArray[0] = num; // store a Value within the array
  }
}

当初始数组创建时(在对象初始化期间),将在堆上分配1000个整数,并设置为0的值。当调用StoreANumber()方法时,num的值仅仅是复制到数组的零元素,而不是存储对其的引用。

引用能力的微妙变化最终决定了某物是引用类型还是值类型,我们应该尽可能使用值类型,以便它们生成栈分配而不是堆分配。任何我们只是发送不需要比当前作用域存活更长时间的数据的情况,都是使用值类型而不是引用类型的好机会。表面上,无论我们将数据传递给同一类的另一个方法还是另一个类的方法,这都没有关系——它仍然是一个将存在于栈上直到创建它的方法超出作用域的值类型。

值传递和引用传递

技术上,每次将数据值作为参数从一个方法传递到另一个方法时,都会进行复制,这无论是值类型还是引用类型都适用。当我们传递对象的数据时,这被称为值传递。当我们只是复制对其他事物的引用时,这被称为引用传递

值类型和引用类型之间的重要区别是,引用类型仅仅是内存中另一个位置的指针,它只占用 4 或 8 个字节(32 位或 64 位,取决于架构),无论它实际上指向什么。当引用类型作为参数传递时,只有这个指针的值被复制到函数中。即使引用类型指向一个巨大的数据数组,这个操作也会非常快,因为被复制的数据非常小。

同时,值类型包含存储在具体对象内的完整和完整的数据位。因此,值类型的数据在它们在方法之间传递或存储在其他值类型时都会被复制。在某些情况下,这意味着传递一个大的值类型作为参数可能比仅仅使用引用类型并让 GC 处理它更昂贵。对于大多数值类型来说,这并不是问题,因为它们的大小与指针相当,但当我们开始讨论下一节中的struct类型时,这一点变得很重要。

数据也可以通过 ref 关键字以引用方式传递,但这与值类型和引用类型的概念非常不同,在我们试图理解底层发生的事情时,非常重要的一点是要在脑海中区分它们。我们可以通过值或引用传递值类型,也可以通过值或引用传递引用类型。这意味着根据传递的类型以及是否使用 ref 关键字,可能会出现四种不同的数据传递情况。

当数据通过引用传递(即使它是值类型)时,对数据的任何更改都会改变原始数据。例如,以下代码将打印出值 10

void Start() {
  int myInt = 5;
  DoSomething(ref myInt);
  Debug.Log(String.Format("Value = {0}", myInt));
}

void DoSomething(ref int val) {
  val = 10;
}

从两个地方都移除 ref 关键字将使其打印出值 5(并且只从其中一个移除会导致编译器错误,因为 ref 关键字需要同时出现在两个位置或都不出现)。这种理解将在我们开始思考一些更有趣的数据类型时派上用场,即结构体、数组和字符串。

结构体是值类型

struct 类型在 C# 中是一个有趣的特殊情况。结构体对象可以包含 privateprotectedpublic 字段;有方法;并且可以在运行时实例化,就像 class 类型一样。然而,两者之间有一个根本的区别:结构体类型是值类型,而 class 类型是引用类型。因此,这导致两者之间的一些重要差异,即结构体类型不支持继承,它们的属性不能赋予自定义默认值(成员数据始终默认为 0null,因为它是一个值类型),并且它们的默认构造函数不能被重写。这大大限制了它们的用途,与类相比,所以简单地将所有类替换为结构体(假设它只是将所有内容分配到栈上)并不像听起来那么简单。

然而,如果我们在一个仅用于将数据块发送到应用程序中其他地方的情况中使用类,并且它不需要超出当前作用域,那么我们可能能够使用 struct 类型,因为 class 类型会导致堆分配,而没有任何特别好的理由:

public class DamageResult {
  public Character attacker;
  public Character defender;
  public int totalDamageDealt;
  public DamageType damageType;
  public int damageBlocked;
  // etc.
}

public void DealDamage(Character _target) {
  DamageResult result = CombatSystem.Instance.CalculateDamage(this, _target);
  CreateFloatingDamageText(result);
}

在这个例子中,我们使用 class 类型从一个子系统(战斗系统)传递大量数据到另一个子系统(UI 系统)。这些数据的唯一目的是被各个子系统计算和读取,因此将其转换为 struct 类型是一个很好的候选方案。

仅将 DamageResult 定义从 class 类型更改为 struct 类型,就可以节省我们很多不必要的垃圾回收,因为它将作为值类型在栈上分配,而不是作为引用类型在堆上分配:

public struct DamageResult {
  // ...
}

这不是一个万能的解决方案。由于结构体是值类型,整个数据块将被复制并传递给调用堆栈中的下一个方法,无论它的大小如何。因此,如果struct对象在长链中的五个不同方法之间通过值传递,那么将同时发生五个不同的栈复制。回想一下,栈的释放是免费的,但栈的分配(涉及数据复制)不是。对于小值,如少量整数或浮点值,这种数据复制几乎可以忽略不计,但反复通过结构体传递大量数据集显然不是一项微不足道的工作,应该避免。

我们可以通过使用ref关键字通过引用传递struct对象来解决这个问题,以最小化每次复制的数据量(仅一个指针)。然而,这可能是危险的,因为通过引用传递允许任何后续方法对struct对象进行更改,在这种情况下,明智的做法是将其数据值设置为readonly。这意味着值只能在构造函数中初始化,并且永远不会再被初始化,即使是它的成员函数也不行,这可以防止在传递链中意外更改。

当结构体包含在引用类型中时,上述所有内容也是正确的,如下所示:

public struct DataStruct {
  public int val;
}

public class StructHolder {
  public DataStruct _memberStruct;
  public void StoreStruct(DataStruct ds) {
      _memberStruct = ds;
  }
}

对于未经训练的眼睛来说,前面的代码看起来像是在尝试将一个栈分配的结构体(ds)存储在一个引用类型(StructHolder)中。这意味着堆上的StructHolder对象现在可以引用栈上的对象吗?如果是这样,当StoreStruct()方法超出作用域并且struct对象(实际上)被删除时会发生什么?事实证明,这些问题都是错误的。

实际上发生的情况是,尽管DataStruct对象(_memberStruct)已经在StructHolder对象中分配在堆上,但它仍然是一个值类型,并且当它是引用类型的成员变量时,并不会神奇地变成引用类型。因此,适用于值类型的所有常规规则都适用。_memberStruct变量不能有null值,并且它的所有字段都将初始化为0null值。当调用StoreStruct()时,ds中的数据将完整地复制到_memberStruct中。没有对栈对象的引用发生,也没有丢失数据的问题。

数组是引用类型

数组的目的在于包含大量数据集,这使得它们难以被视为值类型,因为堆栈上可能没有足够的空间来支持它们。因此,它们被视为引用类型,以便可以通过单个引用传递整个数据集(如果它是值类型,每次传递时都需要复制整个数组)。这与数组包含值类型或引用类型无关。

这意味着以下代码将导致堆分配:

TestStruct[] dataObj = new TestStruct[1000];

for(int i = 0; i < 1000; ++i) {
  dataObj[i].data = i;
  DoSomething(dataObj[i]);
}

然而,以下功能等效的代码不会导致任何堆分配,因为所使用的struct对象是值类型,因此它将在栈上创建:

for(int i = 0; i < 1000; ++i) {
  TestStruct dataObj = new TestStruct();
  dataObj.data = i;
  DoSomething(dataObj);
}

第二个示例中的微妙差异在于,一次只有一个TestStruct存在于栈上,而第一个示例需要通过数组分配1000个。显然,这些方法按现在的写法有点荒谬,但它们说明了需要考虑的一个重要观点。编译器并不足够智能,能够自动为我们找到这些情况并做出相应的更改。通过值类型替换优化内存使用的机遇将完全取决于我们检测它们和理解为什么从引用类型到值类型的转换会导致栈分配,而不是堆分配。

注意,当我们分配引用类型数组时,我们正在创建一个引用数组,它可以提供堆上的其他位置给每个引用。然而,当我们分配值类型数组时,我们正在堆上创建一个值类型的紧凑列表。由于这些值类型不能为null,因此每个值类型都将初始化为0(或等效值),而引用类型数组中的每个引用将始终初始化为null,因为尚未分配任何引用。

字符串是不可变引用类型

我们在第二章“脚本策略”中简要提到了字符串的主题,但现在我们需要更详细地探讨为什么正确使用字符串非常重要。

字符串本质上是由字符组成的数组,因此它们被认为是引用类型,并遵循所有其他引用类型的相同规则;它们将在堆上分配,并且从一种方法复制到另一种方法时,只需复制一个指针。由于字符串实际上是一个数组,这意味着它包含的字符在内存中必须是连续的。然而,我们经常发现自己需要扩展、收缩或组合字符串以创建其他字符串。这可能导致我们对字符串的工作方式产生一些错误的假设。我们可能会假设,由于字符串如此常见且无处不在,对它们进行操作既快又便宜。不幸的是,这是不正确的。字符串并不是为了快速而设计的。它们只是为了方便。

字符串对象类是不可变的,这意味着它们在分配后不能被更改。因此,当我们更改字符串时,我们实际上是在堆上分配了一个全新的字符串来替换它,其中原始字符串的内容将被复制并按需修改到一个全新的字符数组中,而原始字符串对象引用现在指向一个全新的字符串对象。在这种情况下,旧的字符串对象可能不再被任何地方引用,不会在标记-清除过程中被标记,最终会被 GC 清除。因此,懒惰的字符串编程可能导致大量的不必要的堆分配和垃圾回收。

以下代码是一个很好的例子,说明了字符串与普通引用类型的不同:

void TestFunction() {
  string testString = "Hello";
  DoSomething(testString);
  Debug.Log(testString);
}

void DoSomething(string localString) {
  localString = "World!";
}

如果我们错误地认为字符串的工作方式与其他引用类型一样,那么我们可能会被原谅,认为下面的日志输出是World!。看起来testString,一个引用类型,被传递到DoSomething()中,这将改变testString所引用的内容,在这种情况下,Log语句将打印出字符串的新值。

然而,情况并非如此,它只会打印出Hello。实际上发生的情况是,在DoSomething()的作用域内,localString变量一开始引用内存中的同一位置,就像我们处理任何其他引用类型时预期的那样,因为引用是通过值传递的。这给了我们两个指向内存中同一位置的引用,正如我们预期的那样。到目前为止,一切顺利。

然而,一旦我们更改localString的值,我们就会遇到一点冲突。字符串是不可变的,我们不能更改它们,因此,我们必须分配一个新的包含World!值的字符串,并将它的引用分配给localString的值;现在,对Hello字符串的引用数量又回到了一个。因此,testString的值并没有改变,这仍然是Debug.Log()将要打印的值。通过调用DoSomething(),我们成功做到的只是创建了一个新的字符串在堆上,它会被垃圾回收,但并没有改变任何东西。这就是教科书上对浪费的定义。

如果我们将DoSomething()的方法定义更改为通过ref关键字按引用传递字符串,输出确实会变为World!。当然,这也是我们对值类型的预期,这也导致许多开发者错误地假设字符串是值类型。然而,这是一个第四个也是最后一种数据传递情况的例子,其中引用类型是通过引用传递的,这允许我们改变原始引用所引用的内容。

那么,让我们回顾一下:

  • 如果我们按值传递一个值类型,我们只能改变其数据副本的值

  • 如果我们通过引用传递一个值类型,我们可以改变原始传入数据的值

  • 如果我们通过值传递一个引用类型,我们可以修改原始引用变量所引用的对象

  • 如果我们通过引用传递一个引用类型,我们可以改变原始引用所指向的对象

如果我们发现某些函数在被调用时似乎会生成大量的垃圾回收(GC)分配,那么我们可能是因为对先前规则的理解错误而导致了不必要的堆分配。

字符串连接

连接是将字符串附加到另一个字符串上以形成更大的字符串的行为。正如你所学的,任何此类情况都可能导致额外的堆分配。在基于字符串的内存浪费中,最大的罪魁祸首是使用 + 运算符和 += 运算符连接字符串,因为它们引起的分配链效应。

例如,以下代码尝试将一组字符串对象组合起来,以打印关于战斗结果的一些信息:

void CreateFloatingDamageText(DamageResult result) {
  string outputText = result.attacker.GetCharacterName() + " 
             dealt " + result.totalDamageDealt.ToString() + " " + 
             result.damageType.ToString() + " damage to " + 
             result.defender.GetCharacterName() + " (" + 
             result.damageBlocked.ToString() + " blocked)";
  // ...
}

这个函数的一个示例输出可能是一个如下所示的字符串:

Dwarf dealt 15 Slashing damage to Orc (3 blocked)

这个函数包含一些字符串字面量(在应用程序初始化期间分配的硬编码字符串),例如 " dealt ", " damage to ", 和 " blocked)",这些是编译器可以为我们预先分配的简单结构。然而,因为我们在这个组合字符串中使用了其他局部变量,所以它不能在构建时编译掉,因此每次函数被调用时,完整的字符串都会在运行时动态重新生成。

每次执行 ++= 运算符时,都会生成一个新的堆分配。一次只会合并一对字符串,并且每次都会分配一个新的字符串对象。然后,合并的结果将被输入到下一个合并中,并与下一个字符串合并,依此类推,直到构建出最终的字符串对象。

因此,前面的示例将导致在一个语句中分配九个不同的字符串。所有以下字符串都将被分配以满足这个指令,并且最终都需要进行垃圾回收(注意运算符是从右到左解析的):

"3 blocked)"
" (3 blocked)"
"Orc (3 blocked)"
" damage to Orc (3 blocked)"
"Slashing damage to Orc (3 blocked)"
" Slashing damage to Orc (3 blocked)"
"15 Slashing damage to Orc (3 blocked)"
" dealt 15 Slashing damage to Orc (3 blocked)"
"Dwarf dealt 15 Slashing damage to Orc (3 blocked)"

这样就使用了 262 个字符,而不是 49 个。此外,因为字符是一个 2 字节的 数据类型(对于 Unicode 字符串),所以当我们只需要 98 个字节时,就会分配 524 个字节数据。很可能如果这段代码在代码库中存在一次,它就会到处存在;因此,对于一个进行大量类似这种惰性字符串连接的应用程序来说,这会导致大量的内存浪费在生成不必要的字符串上。

注意,大型的、常量的字符串字面量可以使用 ++= 运算符安全地组合。编译器知道你最终需要完整的字符串,并会自动预先生成字符串。这有助于我们在代码库中使大量文本更易于阅读,但前提是它们将产生一个常量字符串。

生成字符串的更好方法是用 StringBuilder 类或几个字符串类方法之一进行字符串格式化。

StringBuilder

传统智慧认为,如果我们大致知道最终字符串的大小,那么我们可以在 AOT(提前优化)时分配一个合适的缓冲区,从而避免不必要的分配。这就是 StringBuilder 类的目的。它实际上是一个可变(可更改)的基于字符串的对象,其工作方式类似于动态数组。它分配一块空间,我们可以将未来的字符串对象复制到其中,并在当前大小超过时分配额外的空间。当然,通过预测我们需要的最大大小并提前分配足够大小的缓冲区,应尽可能避免扩展缓冲区。

当我们使用 StringBuilder 时,可以通过调用 ToString() 方法来检索生成的字符串对象。这仍然会导致为完成的字符串分配一个额外的内存空间,但至少,我们只分配了一个大字符串,而不是使用 ++= 运算符时可能使用的数十个小字符串。

对于前面的示例,我们可能会分配一个容量为 100 个字符的 StringBuilder 缓冲区,以留出足够的空间用于长字符名称和伤害值:

using System.Text;
// ...
StringBuilder sb = new StringBuilder(100);
sb.Append(result.attacker.GetCharacterName());
sb.Append(" dealt " );
sb.Append(result.totalDamageDealt.ToString());
// etc.
string result = sb.ToString();

字符串格式化

如果我们不知道最终字符串的大小,那么使用 StringBuilder 类可能无法生成一个恰好适合结果大小的缓冲区。我们最终可能会得到一个过大的缓冲区(浪费空间),或者更糟糕的是,一个过小的缓冲区,随着我们生成完整的字符串,它必须不断扩展。在这种情况下,最好使用各种字符串类格式化方法之一。

有三种字符串类方法可用于生成字符串:string.Format()string.Join()string.Concat()。它们的工作方式略有不同,但总体输出是相同的。会分配一个新的字符串对象,包含我们传递给它们的字符串对象的内容,并且这一切都是在单一操作中完成的,这减少了多余的字符串分配。

不幸的是,无论我们使用哪种方法,如果我们正在将其他对象转换为额外的字符串对象(例如,在前面示例中生成 "Orc""Dwarf""Slashing" 字符串的调用),那么这将在堆上分配一个额外的字符串对象。我们对此分配无能为力,除非可能缓存结果,这样我们就不需要每次需要时都重新计算它。

在给定情况下,很难说哪种字符串生成方法更有益,因为涉及许多微小的细节,这些细节往往会演变成宗教辩论(只需在 Google 上搜索C# string concatenation performance,你就会明白我的意思),所以最简单的方法是使用之前描述的常规智慧实现一种或另一种方法。每当我们在字符串操作方法中遇到性能问题时,我们也应该尝试另一种方法,以检查它是否会导致性能提升。最确定的方法是为它们两者进行性能分析比较,然后选择最佳选项。

装箱

在 C#中,一切皆对象(有一些例外),这意味着它们都从System.Object类派生。即使是像intfloatbool这样的原始数据类型,也是隐式地从System.Object派生的,而System.Object本身是一个引用类型。这是一个特殊情况,它允许它们访问如ToString()这样的辅助方法,以便它们可以自定义其字符串表示形式,但又不实际上将它们转换为引用类型。每当这些值类型被隐式地以必须作为对象的方式处理时,CLR 会自动创建一个临时对象来存储或装箱其内部的值,以便它可以被当作典型的引用类型对象处理。正如我们所预期的,这会导致堆分配以创建包含容器。

注意,装箱与将值类型用作引用类型的成员变量不是一回事。装箱仅在值类型通过转换或强制类型转换被当作引用类型处理时才会发生。

查看以下示例:

  • 以下代码将导致i整数变量在obj对象内部被装箱:
int i = 128;
object obj = i;
  • 以下代码将使用obj对象表示来替换存储在整数中的值,并将其解箱回整数,存储在i中。i的最终值将是256
int i = 128;
object obj = i;
obj = 256;
i = (int)obj; // i = 256

前面的类型可以动态更改。

  • 以下是完全合法的 C#代码,其中我们重写了obj的类型,将其转换为float
int i = 128;
object obj = i;
obj = 512f;
float f = (float)obj; // f = 512f
  • 以下也是合法的——转换为bool
int i = 128;
object obj = i;
obj = false;
bool b = (bool)obj; // b = false
  • 注意,尝试将obj解箱到不是最近分配的类型会导致InvalidCastException
int i = 128;
object obj = i;
obj = 512f;
i = (int)obj; // InvalidCastException thrown here since most recent conversion was to a float

所有这些都可能有点难以理解,直到我们记住,最终,一切只是内存中的位,我们可以自由地以任何方式解释它们。毕竟,像intfloat等数据类型只是对二进制列表01的抽象。重要的是要知道我们可以通过装箱、转换类型,然后在以后的时间将它们解箱到不同的类型来将我们的原始类型当作对象处理,但每次这样做都会导致堆内存分配。

注意,可以使用许多System.Convert.To…()方法之一将装箱对象的类型转换为其他类型。

装箱可以是隐式的,如前例所示,或者通过类型转换到System.Object来显式。解装箱必须始终通过类型转换回其原始类型来显式进行。每次我们将值类型传递给使用System.Object作为参数的方法时,都会隐式地应用装箱。

例如,String.Format()这样的方法,它接受System.Object作为参数,就是这样一个例子。我们通常通过传递值类型,如intfloatbool,来生成字符串,这些情况下会自动进行装箱,导致额外的堆分配,我们应该注意。Collections.Generic.ArrayList也是这样的例子,因为ArrayList总是将其输入转换为System.Object引用,无论存储了什么类型。

每当我们使用一个接受System.Object作为参数的函数定义,并且传递值类型时,我们应该意识到我们正在隐式地导致堆分配,这是由于装箱造成的。

数据布局的重要性

我们在内存中如何组织数据的重要性可能很容易被忘记,但如果处理得当,可能会带来相当大的性能提升。应尽可能避免缓存未命中,这意味着在大多数情况下,内存中连续的数据数组应该按顺序迭代,而不是其他任何迭代方式。

这意味着数据布局对于垃圾回收也很重要,因为它是迭代进行的,如果我们能找到让 GC 跳过问题区域的方法,那么我们可以潜在地节省大量的迭代时间。

从本质上讲,我们希望将大量引用类型与大量值类型分开。如果值类型(如struct)中包含任何引用类型,那么垃圾回收器(GC)会认为整个对象及其所有数据成员都是间接可引用的对象。在执行标记-清除操作时,它必须验证对象的所有字段才能继续。然而,如果我们将各种类型分开到不同的数组中,那么我们可以让 GC 跳过大部分数据。

例如,如果我们有一个类似以下代码的struct对象数组,那么 GC 将需要迭代每个struct的每个成员,这可能相当耗时:

public struct MyStruct {
    int myInt;
    float myFloat;
    bool myBool;
    string myString;
}

MyStruct[] arrayOfStructs = new MyStruct[1000];

然而,如果我们将这些数据的所有部分重新组织成多个数组,那么 GC 将忽略所有原始数据类型,只检查字符串对象。以下代码将导致垃圾收集扫描速度更快:

int[] myInts = new int[1000];
float[] myFloats = new float[1000];
bool[] myBools = new bool[1000];
string[] myStrings = new string[1000];

这之所以有效,是因为我们给了 GC 更少的间接引用去检查。当数据被分成单独的数组(引用类型)时,它找到三个值类型的数组,标记这些数组,然后立即继续,因为没有理由标记值类型数组的内部内容。它仍然必须遍历 myStrings 中的所有字符串对象,因为每个都是引用类型,并且它需要验证其中没有间接引用。技术上,字符串对象不能包含间接引用,但 GC 在一个层面上工作,它只知道对象是引用类型还是值类型,因此无法区分字符串和类。然而,我们仍然节省了 GC 需要遍历额外的 3,000 份数据(myIntsmyFloatsmyBools 中的 3,000 个值)的需要。

Unity API 中的数组

Unity API 中的几个指令会导致堆内存分配,我们应该对此有所了解。这基本上包括返回数据数组的所有内容。例如,以下方法在堆上分配内存:

GetComponents<T>(); // (T[])
Mesh.vertices; // (Vector3[])
Camera.allCameras; // (Camera[])

每次我们调用返回数组的 Unity API 方法时,都会分配一个新的数据版本。这些方法应尽可能避免使用,或者至少调用一次并缓存,以减少不必要的内存分配。

还有一些 Unity API 调用,我们将元素数组提供给方法,它将必要的数据写入数组。一个这样的例子是将 Particle[] 数组提供给 ParticleSystem 以获取其 Particle 数据。这些类型 API 调用的好处是我们可以避免重新分配大数组,而缺点是数组需要足够大以容纳所有对象。如果我们需要获取的对象数量持续增加,我们可能会发现自己需要重新分配更大的数组。在 ParticleSystem 的情况下,我们需要确保创建的数组足够大,可以包含它在任何给定时间生成的最大数量的 Particle 对象。

Unity Technologies 在过去暗示过,他们可能最终会将一些返回数组的 API 调用更改为需要提供数组的格式。这种形式的 API 在初看时可能会让新程序员感到困惑;然而,与第一种形式不同,它允许负责任的程序员更有效地使用内存。

使用 InstanceID 作为字典键

如第二章所述,脚本策略,字典用于映射两个不同对象之间的关联,它们非常快速地告诉我们是否存在映射,如果存在,映射是什么。将 MonoBehaviourScriptableObject 引用作为字典的键是一种常见做法,但这也引起了一些问题。当访问字典元素时,它将需要调用 UnityEngine.Object 的几个派生方法,这两个对象类型都从 UnityEngine.Object 派生。这使得元素比较和映射获取相对较慢。

这可以通过使用 Object.GetInstanceID() 来改进,它返回一个整数,代表该对象的唯一标识值,在整个应用程序的生命周期中该值永远不会改变,也不会在两个对象之间重复使用。如果我们以某种方式将此值缓存到对象中,并将其用作字典中的键,那么元素比较将比直接使用对象引用快两到三倍。

然而,这种方法也有一些注意事项。如果实例 ID 值未缓存(我们每次需要索引字典时都调用 Object.GetInstanceID())并且我们使用 Mono(而不是 IL2CPP)进行编译,那么元素获取可能会变得缓慢。这是因为它将调用一些线程不安全的代码来获取实例 ID,在这种情况下,Mono 编译器无法优化循环,因此与缓存实例 ID 值相比,会产生一些额外的开销。如果我们使用 IL2CPP 进行编译,它没有这个问题,那么好处仍然不如事先缓存值(大约快 50%)。因此,我们应该努力以某种方式缓存整数值,以避免频繁调用 Object.GetInstanceID()

foreach 循环

foreach 循环关键字在 Unity 开发圈子中是一个有点争议的话题。事实证明,在 Unity C# 代码中实现的许多 foreach 循环在调用过程中会引发不必要的堆内存分配,因为它们在堆上分配了一个 Enumerator 对象作为类,而不是在栈上作为 struct。这完全取决于给定集合的 GetEnumerator() 方法的实现。

注意,在典型数组上使用 foreach 循环是安全的。Mono 编译器秘密地将数组上的 foreach 转换为简单的 for 循环。

自从 Unity 2018.1 以来,Unity 使用了升级的 Mono 运行时(4.0.30319)和一些编译器修复了之前 foreach 的一些问题。因此,在一般情况下,foreach 已不再是重大问题。然而,foreach 在开发者中仍然有很差的声誉。有时它们实际上可能存在问题,这使得一切变得更加复杂。通常,只有一种方法可以确保:使用 Profiler 并检查 foreach 是否确实在你的特定情况下造成问题。

在任何情况下,即使在最糟糕的场景中——也就是说,你的foreach循环实际上正在进行堆分配——成本也是相当可忽略的,因为堆分配成本不会随着迭代次数的增加而增加。只有一个Enumerator对象被分配并反复使用,这总共只花费了几字节内存。所以,除非我们的foreach循环在每次更新时都被调用(这通常本身就很危险),否则成本在小型项目中将主要是可以忽略的。将所有内容转换为for循环所需的时间可能不值得。

协程

如前所述,启动协程最初需要少量内存,但请注意,当方法调用yield时不会产生进一步的成本。如果内存消耗和垃圾回收是重大关注点,我们应该尽量避免有太多短生命周期的协程,并在运行时尽量避免过多调用StartCoroutine()

闭包

闭包是有用的,但危险的工具。匿名方法和 lambda 表达式并不总是闭包,但它们可以是。这完全取决于方法是否使用其自身作用域和参数列表之外的数据。

例如,以下匿名函数不会是一个闭包,因为它自包含并且功能上等同于任何其他局部定义的函数:

System.Func<int,int> anon = (x) => { return x; };

int result = anon(5); // result = 5

然而,如果匿名函数从自身外部拉取数据,它就变成了闭包,因为它关闭了所需数据的周围环境。以下将导致闭包:

int i = 1024;
System.Func<int,int> anon = (x) => { return x + i; };
int result = anon(5);

为了完成这个交易,编译器必须定义一个新的自定义类,该类可以引用i数据值可访问的环境。在运行时,它会在堆上创建相应的对象并将其提供给匿名函数。请注意,这包括值类型(如前例所示),它们最初在栈上,可能最初分配在栈上的目的被抵消了。因此,我们应该预计第二次方法的每次调用都会导致堆分配和不可避免的垃圾回收。

.NET 库函数

.NET 库提供了大量的常用功能,有助于解决程序员在日常实现过程中可能遇到的各种问题。这些类和函数大多数都针对通用用例进行了优化,可能不是特定情况的最佳选择。可能可以用更适合我们特定用例的自定义实现替换特定的.NET 库类。

.NET 库中也有两个大特性,当它们被使用时往往会成为性能瓶颈。这通常是因为它们只是作为一个针对特定问题的快速且简单的解决方案,而没有投入太多精力进行优化。这些特性是LINQ正则表达式

LINQ 提供了一种将数据数组视为小型数据库并使用类似 SQL 的语法对其执行查询的方法。其编码风格的简洁性和底层系统的复杂性(通过其闭包的使用)暗示了它有相当大的开销成本。LINQ 是一个方便的工具,但并不真正适用于高性能、实时应用,如游戏,甚至在不支持 JIT 编译的平台(如 iOS)上也无法运行。

同时,通过Regex类提供的正则表达式允许我们执行复杂的字符串解析,以找到匹配特定格式的子字符串,替换字符串的一部分,或从各种输入中构建字符串。正则表达式是非常有用的工具,但往往在它们在很大程度上不必要的地方或以所谓的巧妙方式实现功能(如文本本地化)时过度使用,而直接字符串替换会更为高效。

对于这两个功能的具体优化远远超出了本书的范围,因为它们可以单独填满一本书。我们应该尽可能地最小化它们的用法,用成本较低的方法替换它们的用法,引入 LINQ 或正则表达式专家来为我们解决问题,或者在网上搜索相关内容以优化我们使用它们的方式。

在网上找到正确答案的最好方法之一就是简单地发布错误的答案。人们可能会出于善意帮助我们,或者会因我们的实现而感到极大冒犯,以至于他们认为纠正我们是他们的公民责任。只是确保在发布之前对主题进行一些研究。即使是最忙碌的人,如果他们看到我们事先已经付出了公平的努力,通常也愿意提供帮助。

临时工作缓冲区

如果我们养成了使用大型的临时工作缓冲区来完成一项或另一项任务的习惯,那么寻找机会重复使用它们而不是反复重新分配它们就很有意义,因为这样可以降低分配和垃圾回收(通常称为内存压力)所涉及的开销。可能值得将此类功能从特定类中提取出来,放入一个包含大量工作区域以供多个类重复使用的通用God 类中。

对象池

谈到临时工作缓冲区,对象池是一种既可最小化又可控制我们内存使用的方法,通过避免分配和重新分配来实现。其思路是制定我们自己的对象创建系统,隐藏我们获取的对象是刚刚分配的还是从之前的分配中回收的。描述此过程的典型术语是“生成”和“销毁”对象,而不是在内存中创建和删除它们。当一个对象被销毁时,我们只是将其隐藏起来,使其处于休眠状态,直到我们再次需要它,此时它将从之前销毁的对象中重新生成,并替代我们可能新分配的对象。

让我们快速实现一个对象池系统:

  1. 首先,我们为要在对象池中使用的对象定义一个公共接口。此系统的一个重要特性是允许池化对象在需要时决定如何回收自己。以下名为IPoolableObject的接口类将很好地满足这一要求:
public interface IPoolableObject{
  void New();
  void Respawn();
}

此接口类定义了两个方法:New()Respawn()。这些方法分别在对象首次创建和被重新生成时调用。

  1. 现在,我们需要实现一个管理池化对象类的类。以下ObjectPool类定义是对象池概念的相当简单的实现:
using System.Collections.Generic;

public class ObjectPool<T> where T : IPoolableObject, new() {
  private Stack<T> _pool;
  private int _currentIndex = 0;

  public ObjectPool(int initialCapacity) {
    _pool = new Stack<T>(initialCapacity);
    for(int i = 0; i < initialCapacity; ++i) {
      Spawn (); // instantiate a pool of N objects
    }
    Reset ();
  }

  public int Count {
    get { return _pool.Count; }
  }

  public void Reset() {
    _currentIndex = 0;
  }

  public T Spawn() {
    if (_currentIndex < Count) {
      T obj = _pool.Peek ();
      _currentIndex++;
      IPoolableObject po = obj as IPoolableObject;
      po.Respawn();
      return obj;
    } else {
      T obj = new T();
      _pool.Push(obj);
      _currentIndex++;
      IPoolableObject po = obj as IPoolableObject;
      po.New();
      return obj;
    }
  }
}

此类允许ObjectPool与任何符合以下两个条件的对象类型一起使用:它必须实现IPoolableObject接口类,并且派生类必须允许无参数构造函数(由类声明中的new()关键字指定)。

  1. 最后,我们需要为任何我们想要池化的对象实现IPoolableObject接口。一个示例池化对象可能如下所示:它必须实现两个public方法,New()Respawn(),这些方法在适当的时候由ObjectPool类调用:
public class EnemyObject : IPoolableObject {
  public void New() {
    // very first initialization here
  }
  public void Respawn() {
    // reset data which allows the object to be recycled here
  }
}

现在,让我们考虑一个使用示例:我们希望有一个连续的怪物浪潮。显然,我们不想不断地创建新的敌人,而是希望回收玩家杀死的敌人。为此,首先我们创建一个包含 100 个EnemyObject对象的池(我们假设我们不需要同时显示超过 100 个敌人):

private ObjectPool<EnemyObject> _objectPool = new ObjectPool<EnemyObject>(100);

ObjectPool上对Spawn()的前 100 次调用将导致敌人被重新生成,每次为调用者提供一个对象的唯一实例。如果没有更多的敌人可以提供(我们已经调用了Spawn()超过 100 次),那么我们将分配一个新的EnemyObject实例并将其推入堆栈。最后,如果对ObjectPool调用Reset(),它将从头开始,回收敌人并将它们提供给调用者。

注意,我们正在使用Stack对象的Peek()方法,这样我们就不从堆栈中移除旧实例。我们希望ObjectPool维护我们创建的所有敌人的引用。

此外,请注意,这个池化解决方案不适用于我们没有定义且无法从IPoolableObject派生的类,例如Vector3Quaternion。这通常由类定义中的sealed关键字决定。在这些情况下,我们需要定义一个包含类:

public class PoolableVector3 : IPoolableObject {
  public Vector3 vector = new Vector3();
  public void New() {
    Reset();
  }
  public void Respawn() {
    Reset();
  }
  public void Reset() {
    vector.x = vector.y = vector.z = 0f;
  }
}

我们可以通过多种方式扩展这个系统,例如定义一个Despawn()方法来处理对象的销毁,当我们在小范围内自动生成和销毁对象时,利用IDisposable接口类和using块,以及/或者允许在池外实例化的对象被添加到池中。

预制体池化

之前的池化解决方案对于典型的 C#对象很有用,但它不适用于专门的 Unity 对象,如GameObjectMonoBehaviour。这些对象往往消耗大量的运行时内存,在创建和销毁时可能会消耗大量的 CPU 资源,并且容易在运行时产生大量的垃圾回收。例如,在一个小型 RPG 游戏的生命周期中,我们可能会生成一千个兽人生物,但在任何给定时刻,我们可能只需要最多 10 个。如果能像之前一样执行类似的池化操作,但对于 Unity Prefabs 来说,可以节省创建和销毁我们不需要的 990 个兽人的大量不必要的开销,那就太好了。

我们的目标是将绝大多数对象实例化推到场景初始化阶段,而不是让它们在运行时创建。这可以在一定程度上节省运行时的 CPU 资源,并避免由于对象创建/销毁和垃圾回收导致的许多峰值,尽管这可能会牺牲场景加载时间和运行时内存消耗。因此,在 Asset Store 上有许多不同的池化解决方案可供处理这项任务,它们在简单性、质量和功能集方面各不相同。

通常建议,任何打算在移动设备上部署的游戏都应该实现池化,因为与桌面应用程序相比,内存分配和释放的额外开销更大。

然而,创建一个池化解决方案是一个有趣的话题,从头开始构建一个可以让我们更好地理解许多重要的 Unity 引擎内部行为。此外,了解这样一个系统是如何构建的,如果我们要使其满足我们特定游戏的需求,而不是依赖于预构建的解决方案,这将更容易进行扩展。

预制件池化的基本思想是创建一个系统,该系统包含从同一预制件引用实例化的活动和非活动 GameObject 的列表。以下图表展示了在经过几个不同预制件(兽人巨魔兽人)的生成、销毁和重生后,系统可能的外观:

图片

注意,前一个截图中的堆内存区域表示内存中存在的对象,而池化系统区域表示池化系统对这些对象的引用。

在这个例子中,每个预制件都实例化了多个实例(11 个兽人8 个巨魔5 个兽人1 条龙)。目前,只有 11 个这些对象是活动的,而其他 14 个已经被先前销毁并且处于非活动状态。请注意,销毁的对象仍然存在于内存中,尽管它们不可见且无法与游戏世界交互,直到它们被重生。自然地,这在我们运行时需要持续占用一定的堆内存来维护非活动对象,但当我们实例化新对象时,我们可以重用现有的一个非活动对象,而不是分配更多内存来满足请求。这显著节省了对象创建和销毁期间的运行时 CPU 成本,并避免了垃圾回收。

以下图表展示了当新兽人被生成时需要发生的事件链:

图片

非活动兽人池中的第一个对象(兽人 7)被重新激活并移动到活动池。我们现在有六个活动的兽人和五个非活动的兽人。

以下图表展示了当兽人对象被销毁时的事件顺序:

图片

这次,对象被停用并从活动池移动到非活动池,留下我们有一个活动的兽人和四个非活动的兽人。

最后,以下图表展示了当生成新对象时,但没有非活动对象来满足请求会发生什么:

图片

在这个场景中,必须分配更多内存来实例化新的对象,因为其非活动池中没有可重用的对象。因此,为了避免为我们的 GameObject 进行运行时内存分配,我们事先知道需要多少以及是否有足够的内存空间一次性容纳它们是至关重要的。这会根据所讨论的对象类型而变化,需要偶尔进行测试和合理性检查,以确保在运行时实例化了合理数量的每个预制件。

考虑到所有这些,让我们为预制件创建一个池化系统。

可池化组件

让我们先定义一个用于池化系统的组件的接口类:

public interface IPoolableComponent {
  void Spawned();
  void Despawned();
}

对于IPoolableComponent的方法将与IPoolableObject的方法大不相同。这次创建的对象是 GameObject,与标准对象相比,它们更难以处理,因为它们的运行时行为中有很大一部分是通过 Unity 引擎处理的,而我们对其的低级访问很少。

GameObjects 没有提供我们可以随时调用的等效New()方法,我们也不能从GameObject类派生以实现它。GameObject 是通过将它们放置在场景中或在运行时通过GameObject.Instantiate()实例化来创建的,我们唯一可以应用的是初始位置和旋转。当然,它们的组件有一个Awake()回调,我们可以定义它,它是在组件第一次被激活时调用的,但这仅仅是一个组合对象——它不是我们实际创建和销毁的父对象。

因此,因为我们只能控制GameObject类组件,所以假设我们想要池化的GameObject类至少有一个组件实现了IPoolableComponent接口类。

每次池化的GameObject类被重新生成时,应该在每个实现组件上调用Spawned()方法,而Despawned()方法则在它被销毁时被调用。这为我们提供了在父GameObject类的创建和销毁过程中控制数据变量和行为的方法。

销毁GameObject的行为很简单:通过SetActive()将其active标志设置为false。这会禁用ColliderRigidbody进行物理计算,将其从可渲染对象列表中移除,并基本上在一次操作中处理与所有内置 Unity 引擎子系统的所有交互。唯一的例外是当前正在对象上调用的任何协程,因为,正如你在第二章中学习的脚本策略,协程是独立于任何Update()GameObject活动的。因此,在销毁此类对象时,我们需要调用StopCoroutine()StopAllCoroutines()

此外,组件通常也会连接到我们自己的自定义游戏子系统,因此Despawn()方法允许我们的组件在关闭之前处理任何自定义清理工作。例如,我们可能希望使用Despawn()来从我们在第二章中定义的消息系统中注销组件,脚本策略

很不幸,成功重新生成 GameObject 要复杂得多。当我们重新生成一个对象时,会有许多设置在对象之前处于活动状态时被遗留下来的,并且这些设置必须重置以避免冲突行为。这个问题的一个常见问题是 Rigidbody 的 linearVelocityangularVelocity 属性。如果这些值在对象重新激活之前没有明确重置,那么新重新生成的对象将继续以旧版本在消失时相同的速度移动。

由于内置组件是 sealed 的,这意味着它们不能被继承,这个问题变得更加复杂。所以,为了避免这些问题,我们可以创建一个自定义组件,在对象消失时重置附加的 Rigidbody 实例:

public class ResetPooledRigidbodyComponent : MonoBehaviour, IPoolableComponent {
  [SerializeField] Rigidbody _body;
  public void Spawned() {  }
  public void Despawned() {
    if (_body == null) {
      _body = GetComponent<Rigidbody>();
      if (_body == null) {
        // no Rigidbody!
        return;
      }
    }
    _body.velocity = Vector3.zero;
    _body.angularVelocity = Vector3.zero;
  }
}

注意,执行清理任务的最佳位置是在消失期间,因为我们不能确定 GameObject 类的 IPoolableComponent 接口类将按什么顺序调用其 Spawned() 方法。在消失期间,另一个 IPoolableComponent 改变对象的速率的可能性不大,但可能存在一个附加到同一对象的不同的 IPoolableComponent 希望在其自己的 Spawned() 方法中将 Rigidbody 的初始速度设置为某个重要值。因此,在 ResetPooledRigidbodyComponent 类的 Spawned() 方法中执行速度重置可能会与其他组件冲突并导致一些非常令人困惑的错误。

事实上,创建不是自包含的且倾向于与其他组件(如本例所示)进行交互的池化组件是实现池化系统时最大的危险之一。我们应该最小化这种设计,并在尝试调试游戏中出现的奇怪问题时定期验证它们。

为了说明,以下是定义一个简单的可池化组件的示例,该组件利用我们在第二章中定义的 MessagingSystem 类。此组件在每次对象生成和消失时自动处理一些基本任务:

public class PoolableTestMessageListener : MonoBehaviour, IPoolableComponent {
  public void Spawned() {
    MessagingSystem.Instance.AttachListener(typeof(MyCustomMessage), 
                                            this.HandleMyCustomMessage);
  }

  bool HandleMyCustomMessage(BaseMessage msg) {
    MyCustomMessage castMsg = msg as MyCustomMessage;
    Debug.Log (string.Format("Got the message! {0}, {1}", 
                             castMsg._intValue, 
                             castMsg._floatValue));
    return true;
  }

  public void Despawned() {
    if (MessagingSystem.IsAlive) {
      MessagingSystem.Instance.DetachListener(typeof(MyCustomMessage), 
                                              this.HandleMyCustomMessage);
    }
  }
}

预制件池化系统

希望现在我们已经理解了我们需要的池化系统,所以剩下的就是实现它。要求如下:

  • 它必须接受请求从 Prefab、初始位置和初始旋转生成 GameObject 实例:

    • 如果已经存在一个消失的版本,它应该重新生成第一个可用的一个

    • 如果不存在,则应该从 Prefab 实例化一个新的 GameObject 实例

    • 在任何情况下,都应在附加到 GameObject 的所有 IPoolableComponent 接口类上调用 Spawned() 方法

  • 它必须接受请求以消失特定的 GameObject 实例:

    • 如果对象由池化系统管理,则应将其停用,并在 GameObject 上调用所有 IPoolableComponent 接口类的 Despawned() 方法

    • 如果对象不由池化系统管理,它应该发送一个错误

要求相当直接,但如果我们希望使解决方案性能友好,实现则需要一些调查。首先,一个典型的单例将是一个好的选择,因为我们希望这个系统可以从任何地方全局访问:

public static class PrefabPoolingSystem {}

对象生成的主任务涉及接受一个 Prefab 引用,并确定是否有任何原本由同一引用实例化的已销毁 GameObject。为此,我们希望我们的池化系统为任何给定的 Prefab 引用跟踪两个不同的列表:一个活跃(生成)GameObject 的列表和一个由它实例化的非活跃(销毁)对象的列表。这些信息最好抽象成一个单独的类,我们将它命名为PrefabPool

为了最大化该系统的性能(从而相对于始终从内存中分配和释放对象所能实现的最大增益),我们希望使用一些快速的数据结构,以便在收到任何生成或销毁请求时获取相应的PrefabPool对象。

由于生成涉及到提供一个 Prefab,我们希望有一个可以快速将 Prefab 映射到管理它们的PrefabPool的数据结构。同样,由于销毁涉及到提供一个GameObject,我们希望有一个可以快速将生成的 GameObject 映射到最初生成它们的PrefabPool实例的数据结构。一对字典将满足这两个需求。

让我们在PrefabPoolingSystem类中定义这些字典:

public static class PrefabPoolingSystem {
  static Dictionary<GameObject,PrefabPool> _prefabToPoolMap = new Dictionary<GameObject,PrefabPool>();
  static Dictionary<GameObject,PrefabPool> _goToPoolMap = new Dictionary<GameObject,PrefabPool>();
}

接下来,我们将定义当我们Spawn一个对象时会发生什么:

public static GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) {
  if (!_prefabToPoolMap.ContainsKey (prefab)) {
    _prefabToPoolMap.Add (prefab, new PrefabPool());
  }
  PrefabPool pool = _prefabToPoolMap[prefab];
  GameObject go = pool.Spawn(prefab, position, rotation);
  _goToPoolMap.Add (go, pool);
  return go;
}

Spawn()方法将提供一个prefab引用、一个初始位置和一个初始旋转。我们需要确定prefab引用属于哪个PrefabPool(如果有的话),要求它使用提供的数据生成一个新的GameObject实例,然后将生成的对象返回给请求者。我们首先检查我们的 Prefab-to-pool 映射,看看是否已经为这个 Prefab 创建了一个池。如果没有,我们立即创建一个。在任何情况下,我们都会要求PrefabPool为我们生成一个新的对象。PrefabPool最终会重新生成一个之前已销毁的对象,或者如果没有剩余的非活跃实例,它会实例化一个新的对象。

这个类并不关心PrefabPool如何创建对象。它只想获取由PrefabPool类生成的实例,以便将其添加到 GameObject-to-pool 映射中,并返回给请求者。

为了方便起见,我们还可以定义一个重载,将对象放置在世界的中心。这对于不可见且只需存在于场景中的 GameObject 很有用:

public static GameObject Spawn(GameObject prefab) {
  return Spawn (prefab, Vector3.zero, Quaternion.identity);
}

注意,目前还没有实际发生生成和销毁。这个任务最终将在PrefabPool类中实现。

销毁涉及接收 GameObject 并确定管理它的 PrefabPool。这可以通过遍历我们的 PrefabPool 对象并检查它们是否包含给定的 GameObject 实例来实现。然而,如果我们最终生成了大量的 Prefab 池,那么这个迭代过程可能需要一段时间。我们最终会有与 Prefab 数量一样多的 PrefabPool 对象(至少,只要我们通过池化系统管理所有这些对象)。大多数项目通常有数十、数百,甚至数千个不同的 Prefab。

因此,GameObject 到池的映射被维护,以确保我们始终可以快速访问最初生成对象的 PrefabPool。它还可以用来快速检查给定的 GameObject 实例是否从一开始就被池化系统管理。以下是销毁方法的定义,它负责这些任务:

public static bool Despawn(GameObject obj) {
  if (!_goToPoolMap.ContainsKey(obj)) {
    Debug.LogError (string.Format ("Object {0} not managed by pool system!", obj.name));
    return false;
  }

  PrefabPool pool = _goToPoolMap[obj];
  if (pool.Despawn (obj)) {
    _goToPoolMap.Remove (obj);
    return true;
  }
  return false;
}

注意,PrefabPoolingSystemPrefabPoolDespawn() 方法都返回一个布尔值,可以用来检查对象是否成功销毁。

因此,多亏了我们维护的两个映射,我们可以快速访问管理给定引用的 PrefabPool 实例,并且这个解决方案可以扩展到系统管理的任何数量的 Prefab。

Prefab 池

现在我们有一个可以自动处理多个 Prefab 池的系统,剩下要定义的就是池的行为。如前所述,我们希望 PrefabPool 类维护两个数据结构:一个用于活动(已生成)对象,这些对象是从给定的 Prefab 实例化的,另一个用于非活动(已销毁)对象。

从技术上讲,PrefabPoolingSystem 类已经维护了一个映射,记录了哪个 Prefab 由哪个 PrefabPool 管理,因此我们可以通过使 PrefabPool 类依赖于 PrefabPoolingSystem 类来提供它所管理的 Prefab 的引用,从而节省一点内存。因此,这两个数据结构将是 PrefabPool 需要跟踪的唯一成员变量。

然而,对于每个生成的 GameObject,它还必须维护一个包含所有 IPoolableComponent 引用的列表,以便在它们上调用 Spawned()Despawned() 方法。在运行时获取这些引用可能是一个昂贵的操作,因此最好将这些数据缓存在一个简单的 struct 中:

public struct PoolablePrefabData {
  public GameObject go;
  public IPoolableComponent[] poolableComponents;
}

这个 struct 将包含对 GameObject 的引用以及所有其 IPoolableComponent 组件的预缓存列表。

现在,我们可以定义我们的 PrefabPool 类的成员数据:

public class PrefabPool {
  Dictionary<GameObject,PoolablePrefabData> _activeList = new Dictionary<GameObject,PoolablePrefabData>();
  Queue<PoolablePrefabData> _inactiveList = new Queue<PoolablePrefabData>();
}

活动列表的数据结构应该是一个字典,以便从任何给定的 GameObject 引用快速查找相应的 PoolablePrefabData 组件。这在对象销毁时将非常有用。

同时,非活动数据结构被定义为Queue,但它也可以同样好地工作作为ListStack或任何需要定期扩展或收缩的数据结构,其中我们只需要从组的一端弹出项目,因为哪个对象不重要。重要的是我们能够检索到其中一个。Queue在这种情况下很有用,因为我们可以在单个Dequeue()调用中从数据结构中检索并移除对象。

对象生成

让我们在我们的池化系统上下文中定义一下“生成GameObject”的含义:在某个时刻,PrefabPool将接收到一个请求,从特定的预制件生成GameObject,并指定其位置和旋转。我们首先应该检查是否有任何非活动状态的预制件实例。如果有,那么我们可以从Queue中取出下一个可用的实例并重新生成它。如果没有,那么我们需要使用GameObject.Instantiate()从预制件中实例化一个新的GameObject。在这个时候,我们还应该创建一个PoolablePrefabData对象来存储GameObject引用并获取所有附加到其上的实现IPoolableComponentMonoBehaviours列表。

无论哪种方式,我们现在都可以激活GameObject,设置其位置和旋转,并在所有IPoolableComponent引用上调用Spawned()方法。一旦对象被重新生成,我们就可以将其添加到活动对象列表中,并将其返回给请求者。

以下是对定义此行为的Spawn()方法的定义:

public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) {    
  PoolablePrefabData data;

  if (_inactiveList.Count > 0) {
    data = _inactiveList.Dequeue();
  } else {
    // instantiate a new object
    GameObject newGO = GameObject.Instantiate(prefab, position, rotation) as GameObject;
    data = new PoolablePrefabData();
    data.go = newGO;
    data.poolableComponents = newGO.GetComponents<IPoolableComponent>();
  }

  data.go.SetActive (true);
  data.go.transform.position = position;
  data.go.transform.rotation = rotation;

  for(int i = 0; i < data.poolableComponents.Length; ++i) {
    data.poolableComponents[i].Spawned ();
  }
  _activeList.Add (data.go, data);

  return data.go;
}

实例预先生成

由于我们每次PrefabPool耗尽已销毁的实例时都会使用GameObject.Instantiate(),因此这个系统并不能完全消除我们运行时对象实例化以及堆内存分配的需求。在当前场景的生命周期中,预先生成我们预期需要的实例数量非常重要,这样我们就可以最小化或消除在运行时需要更多实例化的需求。

注意,我们不应该预先生成太多的对象。如果我们预期在任何给定时间内场景中最多只能看到三到四个爆炸粒子效果,那么预先生成 100 个爆炸粒子效果将是浪费的。相反,生成太少的实例会导致过度的运行时内存分配,而这个系统的目标是尽可能将大多数分配推到场景生命周期的开始。我们需要小心地维护内存中的实例数量,以避免浪费比必要的更多内存空间。

让我们在PrefabPoolingSystem类中定义一个方法,我们可以用它快速从预制件预先生成指定数量的对象。这本质上涉及生成N个对象,然后立即销毁它们:

public static void Prespawn(GameObject prefab, int numToSpawn) {
  List<GameObject> spawnedObjects = new List<GameObject>();

  for(int i = 0; i < numToSpawn; i++) {
    spawnedObjects.Add (Spawn (prefab));
  }

  for(int i = 0; i < numToSpawn; i++) {
    Despawn(spawnedObjects[i]);
  }

  spawnedObjects.Clear ();
}

我们会在场景初始化期间使用此方法预先生成一组对象以在关卡中使用。例如,以下代码:

public class OrcPreSpawner : MonoBehaviour
  [SerializeField] GameObject _orcPrefab;
  [SerializeField] int _numToSpawn = 20;

  void Start() {
    PrefabPoolingSystem.Prespawn(_orcPrefab, _numToSpawn);
  }
}

对象销毁

最后,是对象销毁的行为。如前所述,这主要涉及禁用对象,但我们还需要处理各种账务任务,并在所有IPoolableComponent引用上调用Despawned()

下面是PrefabPool.Despawn()方法的定义:

public bool Despawn(GameObject objToDespawn) {
  if (!_activeList.ContainsKey(objToDespawn)) {
    Debug.LogError ("This Object is not managed by this object pool!");
    return false;
  }

  PoolablePrefabData data = _activeList[objToDespawn];

  for(int i = 0; i < data.poolableComponents.Length; ++i) {
    data.poolableComponents[i].Despawned ();
  }

  data.go.SetActive (false);
  _activeList.Remove (objToDespawn);
  _inactiveList.Enqueue(data);
  return true;
}

首先,我们验证对象是否由池管理,然后获取相应的PoolablePrefabData实例以访问IPoolableComponent引用的列表。一旦在所有这些对象上调用Despawned(),我们就禁用对象,将其从活动列表中移除,并将其推入非活动队列,以便稍后重新生成。

预制件池测试

以下类定义允许我们对PrefabPoolingSystem类进行简单的实际测试;它将在应用程序初始化期间支持三个预制件,并为每个预制件预先生成五个实例。我们可以按1234键生成每种类型的一个实例,然后按QWER键分别销毁每种类型的随机实例:

public class PrefabPoolingTestInput : MonoBehaviour {
  [SerializeField] GameObject _orcPrefab;
  [SerializeField] GameObject _trollPrefab;
  [SerializeField] GameObject _ogrePrefab;
  [SerializeField] GameObject _dragonPrefab;

  List<GameObject> _orcs = new List<GameObject>();
  List<GameObject> _trolls = new List<GameObject>();
  List<GameObject> _ogres = new List<GameObject>();
  List<GameObject> _dragons = new List<GameObject>();

   void Start() {
     PrefabPoolingSystem.Prespawn(_orcPrefab, 11);
     PrefabPoolingSystem.Prespawn(_trollPrefab, 8);
     PrefabPoolingSystem.Prespawn(_ogrePrefab, 5);
     PrefabPoolingSystem.Prespawn(_dragonPrefab, 1);
   }

  void Update () {
    if (Input.GetKeyDown(KeyCode.Alpha1)) {SpawnObject(_orcPrefab, _orcs);}
    if (Input.GetKeyDown(KeyCode.Alpha2)) {SpawnObject(_trollPrefab, _trolls);}
    if (Input.GetKeyDown(KeyCode.Alpha3)) {SpawnObject(_ogrePrefab, _ogres);}
    if (Input.GetKeyDown(KeyCode.Alpha4)) {SpawnObject(_dragonPrefab, _dragons);}
    if (Input.GetKeyDown(KeyCode.Q)) { DespawnRandomObject(_orcs); }
    if (Input.GetKeyDown(KeyCode.W)) { DespawnRandomObject(_trolls); }
    if (Input.GetKeyDown(KeyCode.E)) { DespawnRandomObject(_ogres); }
    if (Input.GetKeyDown(KeyCode.R)) { DespawnRandomObject(_dragons); }
  }

  void SpawnObject(GameObject prefab, List<GameObject> list) {
    GameObject obj = PrefabPoolingSystem.Spawn (prefab, 
                                                5.0f * Random.insideUnitSphere, 
                                                Quaternion.identity);
    list.Add (obj);
  }

  void DespawnRandomObject(List<GameObject> list) {
    if (list.Count == 0) {
       // Nothing to despawn
       return;
    }

    int i = Random.Range (0, list.Count);
    PrefabPoolingSystem.Despawn(list[i]);
    list.RemoveAt(i);
  }
}

一旦我们为任何预制件生成超过五个实例,它将需要在内存中实例化一个新的实例,这会消耗一些内存分配。然而,如果我们观察 Profiler 窗口中的内存区域,当我们只生成和销毁已存在的实例时,我们会注意到绝对没有新的分配发生。

预制件池化和场景加载

这个系统有一个重要但尚未提到的注意事项:PrefabPoolingSystem类将超出场景的生命周期,因为它是一个静态类。这意味着当加载新场景时,池化系统的字典将尝试维持对前一个场景中任何池化实例的引用,但 Unity 会强制销毁这些对象,无论我们是否仍然保留对这些对象的引用(除非它们被设置为DontDestroyOnLoad()),因此字典将充满null引用。这会给下一个场景带来一些严重问题。

因此,我们应该在PrefabPoolingSystem中创建一个方法,以便为这个可能的事件重置池化系统。在加载新场景之前应该调用以下方法,以确保它为下一个场景中Prespawn()的早期调用做好准备:

public static void Reset() {
  _prefabToPoolMap.Clear ();
  _goToPoolMap.Clear ();
}

注意,如果我们还在场景转换期间调用垃圾回收,就没有必要明确销毁这些字典所引用的PrefabPool对象。因为这些是PrefabPool对象的唯一引用,它们将在下一次垃圾回收期间被回收。如果我们不在场景之间调用垃圾回收,那么PrefabPoolPooledPrefabData对象将保留在内存中,直到那时。

预制件池化总结

此池化系统为 GameObject 和 Prefab 的运行时内存分配问题提供了一个不错的解决方案,但作为一个快速提醒,我们需要注意以下注意事项:

  • 我们需要小心正确重置重生对象中的重要数据(例如Rigidbody速度)。

  • 我们必须确保我们不会预先生成太多或太少 Prefab 的实例。

  • 我们应该小心IPoolableComponent上的Spawned()Despawned()方法的执行顺序,不要假设它们将以特定的顺序被调用。

  • 当加载新场景时,我们必须在PrefabPoolingSystem上调用Reset()以清除任何不再存在的对象的null引用。

我们可以实现一些其他功能。如果希望在将来扩展此系统,这些功能将被留作学术练习:

  • 在初始化后添加到GameObject的任何IPoolableComponent都不会调用其Spawned()Despawned()方法,因为我们只在GameObject首次实例化时收集这个列表。我们可以通过将PrefabPool更改为每次Spawned()Despawned()被调用时都获取IPoolableComponent引用来修复这个问题,但这可能会在生成/销毁期间增加额外的开销。

  • 附加到 Prefab 根节点子节点的任何IPoolableComponent也不会被计算。这可以通过将PrefabPool更改为使用GetComponentsInChildren<T>来修复,但这可能会增加额外的开销,如果我们使用具有深层层次结构的 Prefab。

  • 场景中已经存在的 Prefab 实例将不会被池化系统管理。我们可以创建一个需要附加到此类对象上的组件,并在其Awake()回调中通知PrefabPoolingSystem类其存在,并将引用传递给相应的PrefabPool

  • 我们可以实现一种方法,让IPoolableComponent在获取时设置一个优先级,并直接控制其Spawned()Despawned()方法的执行顺序。

  • 我们可以添加计数器,跟踪对象在 Inactive 列表中相对于整个场景生命周期的持续时间,并在关闭时打印出数据。这可以告诉我们是否预先生成了太多给定 Prefab 的实例。

  • 这个系统不会友好地与设置为DontDestroyOnLoad()的 Prefab 实例交互。可能明智的做法是为每个Spawn()调用添加一个布尔值,以说明对象是否应该持续存在,并将它们保存在一个在Reset()期间不会被清除的单独数据结构中。

  • 我们可以将Spawn()函数修改为接受一个参数,允许请求者传递自定义数据到IPoolableObjectSpawned()函数,用于初始化目的。这可以采用与第二章中我们消息系统的Message类派生自定义消息对象类似的方法,脚本策略

IL2CPP 优化

Unity Technologies 发布了一些关于在特定情况下提高 IL2CPP 性能的有趣方法的博客文章,但它们可能难以管理。如果你使用 IL2CPP 并且需要从我们的应用程序中榨取最后一点性能,那么请查看以下链接中的博客系列:

WebGL 优化

Unity Technologies 还发布了几篇关于 WebGL 应用的博客文章,其中包含所有 WebGL 开发者都应该了解的关于内存管理的关键信息。这些文章可以在以下链接中找到:

摘要

在本章中,我们涵盖了大量的理论和语言概念,希望这些内容能帮助大家了解 Unity 引擎和 C#语言的内部工作原理。这些工具尽力减轻我们复杂内存管理的负担,但在开发游戏的过程中,我们仍需关注一系列问题。在编译过程、多个内存域、值类型与引用类型的复杂性、按值传递与按引用传递、装箱、对象池以及 Unity API 中的各种怪癖等方面,你有很多事情需要担心。然而,通过足够的实践,你将学会克服这些问题,而无需不断查阅像这样的大部头!

通过本章,我们已经涵盖了经典 Unity 中所有可能的优化区域。然而,随着 2019.1 版本的发布,Unity 官方引入了面向数据的技术堆栈DOTS),一套全新的基本 API,用于访问一个全新的优化级别,尤其是在现代大规模多线程系统中。跟随我进入下一章,我们将探索这个新的前沿领域。

第九章:数据导向技术堆栈

近年来,我们看到了向多线程编程的大幅推进。原因很明显:虽然我们在单个核心的速度上已经达到了技术极限,但我们已经发现了如何有效地将数千个核心放入我们的硬件中,并使每段代码并行运行以获得巨大的性能提升。

然而,从单线程编程转向多线程编程并不简单。并非每个算法都可以轻易地分割成片段,即使可以,你还需要注意几个细节,以避免奇怪和不可预测的行为。

当 Unity 的第一个版本在 2005 年发布时,大规模多线程几乎是一个未来场景。然而,十四年对于游戏开发来说相当于一个地质时代,游戏引擎需要适应自己以跟上尖端技术的步伐。

Unity 目前正在努力将其核心设计适应一个由大规模多线程主导的世界。这项努力被称为数据导向技术堆栈DOTS)。

在本章中,我们将探讨 DOTS 的组件:

  • 任务系统

  • 实体组件系统ECS

  • Burst 编译器

Unity 中的 DOTS 堆栈非常实验性,尽管它是公开的,但所有组件仍处于早期预览状态,这意味着它们的使用应避免用于重要项目。它们也变化非常快。官方关于 ECS 的教程在learn.unity.com上现在已不可用,因为它已经过时。如果几个月后,这一章节包含许多已弃用的函数和过程,我也不会感到惊讶。不要担心;我将在文末提供获取 DOTS 最新消息的链接。

多线程的问题

视频游戏具有巨大的多线程潜力。从理论上讲,每个GameObject都可以被视为一个独立的实体,拥有自己的生命周期和计算路径。这将通过大量的GameObject实例瞬间提高你的游戏性能。假设处理所有GameObject的更新需要 1 毫秒。如果你能有一千个类似的GameObject实例,那将需要整整一秒,但如果你能将每个更新分配给一个单独的核心,所有更新都可以并行运行,你的总计算时间将正好是 1 毫秒。这代表着 100,000%的速度提升!

不幸的是,这并不容易。正如我们之前所说,你不能只是将一段代码分配给一个核心并期望一切都能正常工作。编写多线程代码的一个大问题是存在竞争条件、死锁以及难以重现和调试的 bug 的风险。

竞态条件是指两个或多个计算在向完成的方向竞争,但实际结果取决于它们完成的顺序。想象一个线程试图将一个数字加三,而另一个线程将其乘以四。结果将取决于哪个操作先发生。死锁是一个问题,其中两个或多个线程正在竞争共享资源,每个线程都需要完整的资源集合来完成其任务,但每个线程都保留了一小部分资源,并且拒绝将其控制权交给另一个线程,在这种情况下,没有任何线程可以完成任何工作,因为它们都没有需要的完整集合。

由于这个原因,传统上,Unity API 不是线程安全的,这意味着它们不能被并行运行的多个线程调用。因此,几乎所有的 Unity 代码都在主线程中运行,包括每个GameObjectMonoBehaviour(这就是为什么如果你阻塞单个更新,你可能会冻结整个 Unity 编辑器)。

因为多线程是一个复杂的话题,我们将一步一步地通过一个例子来讲解。

一个小例子

假设你想要在场景中有成千上万的类似物品。这不是一个奇怪的要求;可能有许多合理的理由:你可能希望在巨大的星际战斗中渲染成千上万的飞船,或者你可能想要为实时策略RTS)游戏动画化成千上万的单位,或者你可能想要处理大量的粒子。

为了简单起见,在我们的演示中,我们想要一个有 10,000 个旋转立方体的场景。所以,让我们开始吧:

  1. 每个立方体将有一个单独的MonoBehaviour实例,它执行一个非常简单的旋转立方体操作:
using UnityEngine;

namespace Classic
{
  public class Rotator : MonoBehaviour
  {

    public float rotationSpeed;

    void Update()
    {
      transform.Rotate(0f, rotationSpeed * Time.deltaTime, 0f);
    }
  }
}

脚本是自我解释的:我们有一个公共变量rotationSpeed,用于存储立方体的旋转速度。然后,在Update方法中,我们简单地旋转立方体。

  1. 现在,我们不想手动将 10,000 个立方体插入场景。所以,我们将创建一个游戏管理器,它将执行以下操作:

    1. 在场景中生成 10,000 个立方体

    2. 为每个立方体设置随机的旋转速度

  2. 因此,我们创建一个空的GameObject,并将其附加到一个游戏管理脚本,如下所示:

using UnityEngine;
using System;

namespace Classic { 

    public class ClassicCubeManager : MonoBehaviour
    {

        #region COMMON_GAME_MANAGER_DATA
        public float cubeSpacing = 0.1f;
        public int width = 10;
        public int height = 10;

        public GameObject cubePrefab;
        #endregion

        void Start()
        {
            SpawnCubes();

        }

        private void SpawnCubes()
        {
            Debug.Log(String.Format("Spawning {0} cubes", (width / cubeSpacing) * (height / cubeSpacing)));
            Vector3 position = new Vector3();
            while (position.x < width)
            {
                while (position.y < height) {
                    var newCube = GameObject.Instantiate(cubePrefab);
                    newCube.transform.position = position;
                    newCube.GetComponent<Rotator>().rotationSpeed = UnityEngine.Random.Range(25.0f, 50.0f);
                    position = new Vector3(position.x, position.y + cubeSpacing, 0f);
                }
                position = new Vector3(position.x + cubeSpacing, 0f, 0f);
            }
        }

    }
} 

脚本简单地取一个cubePrefab,并在一个宽 x 高的矩形空间中生成一定数量的它们。令人兴奋的部分是SpawnCubes函数。该函数从原点开始,生成立方体,直到我们达到对角线。这是一个相当标准的脚本。

  1. 现在我们可以运行它了,我们应该能看到类似这样的结果:

正如你所见,帧率并不理想。查看右上角的统计数据,你可以看到游戏正在以大约 22 FPS 的速度运行。

注意,这些值是从我的非最优机器上获取的。你可能会得到不同的值。如果你的电脑非常快,演示运行得非常完美,尝试将立方体的数量增加到 20,000 个或更多。

  1. 这个 FPS 值不是最优的。然而,我们可以打开 Profiler 窗口(窗口 | 分析 | Profiler),并尝试了解应用程序的行为:

图像很清晰:我们几乎分配了 1 GB 的 RAM,并且每帧花费 45 毫秒,其中 10 毫秒仅用于脚本。这是错误的。更新脚本很简单:我们只是在每一帧旋转一个立方体几度。

我们应该做得更好。我们会的。

Unity 工作系统

DOTS 中可以为我们提供巨大性能提升功能的大块是C#工作系统。像所有其他 DOTS 组件一样,这个功能仍在积极开发中,但自 Unity 2019.1 以来已经公开,因此尽早熟悉它而不是晚些时候是明智的,因为它将对 Unity 开发者编写高性能代码的方式带来重大变化:

正如我们将看到的,使用此系统与不使用此系统的游戏质量差异可能会非常明显,这可能会在 Unity 开发社区中引起一些碎片化。了解并利用新工作系统的优势,以便我们的应用程序具有最大的成功潜力,这是我们的最佳利益所在。

C#工作系统的想法是能够创建简单的任务,这些任务在后台线程上运行,以从主线程卸载工作。C#工作系统非常适合那些明显可以并行处理的任务,例如在场景中同时操作数十万个简单的 AI 代理,以及任何可以归结为数千个小而独立的操作的任何问题。当然,它也可以用于典型的多线程行为,我们在后台执行一些不需要立即进行的计算。工作系统还引入了一些编译器技术改进,以获得比仅仅将任务移动到单独的线程更大的性能提升。

基本工作

从本质上讲,一个工作就是一个在单独线程上运行的功能:

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public struct SimpleJob : IJob
{
    // Put here a bunch of data...
    public float number;

    public NativeArray<float> data;

    // Write your Execute() function.
    public void Execute()
    {
        data[0] += number;
    }
}

每个工作都是一个扩展IJobinterface接口的结构体。结构体包含我们希望工作使用的任何数据,以及一个名为Execute的函数,用于在作业中执行的操作。例如,上一个示例只是将一定数量的值加到数组的第一个元素上。

因为,正如我们之前所说的,多线程是一个非常棘手的事情,Unity 在你可以传递和接收数据到工作(或一组工作)的方式上提供了一些限制。主要约束是Execute函数不接受任何参数,也不能返回任何值。工作所需的所有数据都必须复制到结构体中,结果也必须写入结构体。

需要将内容复制到结构体中的事实似乎是一个重大的限制:你不能传递一个MonoBehaviour实例的引用或List的引用。幸运的是,Unity 提供了一种使用一组线程安全的包装器通过原生容器访问作业共享内存的方法。

原生容器包括以下内容:

  • NativeArray: 数据的简单集合(线程安全的 C#数组的等价物)

  • NativeList: 与NativeArray类似,但可调整大小(线程安全的List的等价物)

  • NativeHashMap: HashMap的线程安全版本

  • NativeMultiHashMap: 与NativeHashMap类似,但每个键可以有多个值

  • NativeQueue: 一个线程安全的先进先出FIFO)队列

因此,在我们的工作中,我们使用一个固定大小的NativeArray变量来存储输入和输出数据。

现在,我们想要运行这个任务。为了做到这一点,我们需要使用MonoBehaviour来初始化和运行它:

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class SimpleJobRunner : MonoBehaviour
{

    public float numberToAdd = 5;

    private NativeArray<float> theData;

    private JobHandle simpleJobHandle;

    void Start()
    {
        theData = new NativeArray<float>(1, Allocator.Persistent);
        theData[0] = 2;

        SimpleJob simpleJob = new SimpleJob
        {
            number = numberToAdd,
            data = theData
        };

        simpleJobHandle = simpleJob.Schedule();

        JobHandle.ScheduleBatchedJobs();

        simpleJobHandle.Complete();

        if (simpleJobHandle.IsCompleted)
        {
            Debug.Log(simpleJob.data[0]);
        }

                theData.Dispose();
    }
}

Start方法中,我们首先创建一个空的NativeArrayNativeArray构造函数的第一个参数是大小;第二个参数是Allocator。实际上有三个分配器:

  • Allocator.Temp: 这是三者中最快的,但它的生命周期必须在一个帧之内。实际上,Unity 强制你在函数返回之前调用Dispose来处理这样的数组。因此,我们不能将Allocator.Temp用于作为作业参数传递的原生容器。作业不保证在它们开始启动的同一帧内完成。

  • Allocator.TempJob: 这比Allocator.Temp慢,其生命周期限制在四个帧以下。这是传递给简单作业的完美类型的Allocator,这些作业运行并快速返回,例如我们例子中的那种。

  • Allocator.Persistent: 这是三者中最慢的,但它的生命周期是无限的。这是你想要存储持久数据或作业需要长时间访问的数据的Allocator类型。

之后,我们创建一个新的SimpleJob实例,将numberdata传递给它。然后,我们使用Schedule函数安排和运行作业。这将返回一个jobHandle实例,我们可以用它来控制作业执行。最后,我们等待作业完成,然后打印结果。一切看起来都像是标准的 C#代码,但作业是在一个单独的线程中运行的!

记住要做一个好的 C#公民,并且总是手动处理原生容器!你不想以污染内存的方式污染我们的世界,就像你不想以污染我们的世界的方式污染我们的世界一样。

在这一点上,如果你将SimpleJobRunner附加到一个空对象上,你应该在调试控制台中看到打印的结果。所有操作都是以多线程的方式进行的。

一个更复杂的例子

只为了求和两个数字就启动一个作业绝对不是最优编程的例子。Unity 创建作业是为了运行数千个,将繁重的工作提升到多线程领域。

因此,我们现在将修改我们之前的旋转立方体示例,以便实际的旋转由任务执行。我们首先想要做的是创建我们的任务,如下所示:

using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Jobs;

namespace JobSystem
{

    public struct RotatorJob : IJobParallelForTransform
    {

        [ReadOnly]
        public NativeList<float> speeds;

        [ReadOnly]
        public float deltaTime;

        public void Execute(int index, TransformAccess transform)
        {
            Vector3 currentRotation = transform.rotation.eulerAngles;
            currentRotation.y += speeds[index] * deltaTime;
            transform.rotation = Quaternion.Euler(currentRotation);
        }
    }
}

这个任务稍微复杂一些,但不用担心。首先,它扩展了 IJobParallelForTransform;这是一个用于运行 GameObject 实例并行变换的专用任务接口。你也可以通过扩展 IJob 来实现同样的功能,但由于这是一个非常常见的用例,Unity 为我们编写了大部分代码。正如你所见,主要区别在于 Execute 现在有两个参数。在我们的演示中,我们想在我们的 10,000 个立方体中的每一个上运行相同的任务。在这种情况下,参数如下:

  • index 代表场景中的 index^(th) 立方体

  • transform 是对 index^(th) 立方体 Transform 的引用

我们的任务接受两个输入:

  • speeds: 它是一个包含每个立方体所有随机速度的数组。记住,我们无法获取特定 GameObject 的某些数据的引用,因此我们需要将所有速度写入共享内存。请注意,字段是 [ReadOnly];我们不希望 i^(th) 立方体能够改变另一个立方体的速度。

  • deltaTime: 由于任务完全与 Unity 引擎解耦,它无法访问 Time 和其他线程不安全的 Unity 部分。因此,我们需要自己传递 deltaTime

Execute 函数很简单;我们只是旋转立方体。

现在,我们需要在每个立方体上调用这些任务,并且我们需要使用游戏管理器来完成这个任务:

namespace JobSystem
{
    public class JobCubeManager : MonoBehaviour
    {

        #region COMMON_GAME_MANAGER_DATA
        public float cubeSpacing = 0.1f;
        public int width = 10;
        public int height = 10;

        public GameObject cubePrefab;
        #endregion

        TransformAccessArray transformAccessArray;
        Unity.Jobs.JobHandle jobHandle;
        NativeList<float> speeds;

                ...

我们首先定义与经典示例相同的基本数据。前几个属性是相同的;有趣的是最后三个:

  • transformAccessArray 是我们将存储所有立方体 transform 实例引用的数组。这就是我们的任务如何访问它们的方式。

  • jobHandle 是我们将用于查询任务系统任务状态的句柄。

  • speeds 是之前描述的随机速度列表:

        void Start()
        {
            transformAccessArray = new TransformAccessArray(0, -1);
            speeds = new NativeList<float>(1, Allocator.Persistent);
            SpawnCubes();
        }

Start 方法中,我们只是初始化所有原生容器,然后生成立方体。请注意,我们使用 Allocator.Persistent 分配器,因为我们想在 Start 时初始化速度,然后在应用程序的整个生命周期中使用相同的列表:

        private void SpawnCubes()
        {
            Debug.Log(String.Format("Spawning {0} cubes", (width / cubeSpacing) * (height / cubeSpacing)));
            Vector3 position = new Vector3();
            while (position.x < width)
            {
                while (position.y < height)
                {
                    var newCube = Instantiate(cubePrefab);
                    newCube.transform.position = position;
                    position = new Vector3(position.x, position.y + cubeSpacing, 0f);
                    transformAccessArray.Add(newCube.transform);
                    speeds.Add(UnityEngine.Random.Range(25.0f, 50.0f));
                }
                position = new Vector3(position.x + cubeSpacing, 0f, 0f);
            }

        }

SpawnCubes 函数与之前的非常相似。然而,有两行关键代码不同:

  1. 在我们实例化一个立方体后,我们将它的 transform 添加到 transformAccessArray

  2. 我们不是在立方体的 Rotator MonoBehaviour 中设置随机速度,而是在速度数组中设置。实际上,我们不应该在 Prefab 中有 Rotator 组件!

现在,每一帧,我们都需要在所有立方体上并行运行任务:

        void Update()
        {
            jobHandle.Complete();

            if (jobHandle.IsCompleted)
            {
                var rotatorJob = new RotatorJob()
                {
                    deltaTime = Time.deltaTime,
                    speeds = speeds
                };

                jobHandle = rotatorJob.Schedule(transformAccessArray);
                JobHandle.ScheduleBatchedJobs();
            }

        }

我们使用与之前相同的模式。我们检查之前的任务是否已经完成,实例化一个新的任务,设置数据,然后在整个 transformAccessArray 上调度任务。

如果一切正常,我们可以运行游戏并看到与之前相同的场景。现在,然而,我们可以享受 ~35 FPS:

查看 Profiler,我们可以看到,现在脚本(下方的蓝色部分)所用的时间几乎看不见。它从 10 毫秒下降到 1 毫秒。这是一个 90%的改进!

然而,我们仍然有一个问题。我们的场景被 10,000 个GameObject实例、10,000 个Transforms、10,000 个MeshRenderers和另外 10,000 个不同组件的副本所填满。MonoBehaviourGameObject是重量级的数据结构,它们消耗了相当数量的内存和 CPU 周期。

我们能做得更好吗?是的,我们可以。

新的 ECS

ECS 是一个勇敢而雄心勃勃的尝试,重新设计 Unity 设计核心基础:GameObject-MonoBehaviour范式。正如你可以想象的那样,改变游戏中每个对象的基本设计模式不是一件容易的任务。所以你可能想知道:为什么?

有几个原因。让我们客观地看看其中的一些:

  • 首先,正如我们之前所说的,GameObjectMonoBehaviour是重量级对象;它们携带大量的内部代码和数据结构。GameObject实例和MonoBehaviour引入的开销足以限制屏幕上可以拥有的对象数量,这比渲染它们所需的资源还要多。这对抽象模型来说不是一件好事。

  • 其次,MonoBehaviour实例散布在内存中。这意味着GameObject需要在内存中搜索以检索它连接的所有MonoBehaviour实例,并且系统依赖于引用。这有两个问题:它使缓存非常低效,更重要的是,当我们在大规模多线程应用程序中使用GameObject实例时,例如通过使用作业(我们已经看到作业不能安全地使用引用),这是一个问题。

  • 最后但同样重要的是,从代码设计角度来看,MonoBehaviour实例存在一个问题:它们存储数据和行为。这并不是一个大问题。毕竟,许多令人惊叹的游戏都是使用这种范式发布的。然而,在软件架构中,通常将数据(通常称为模型)与使用数据的算法(通常称为控制器)分开。*

相反,ECS 朝着将数据与行为分离的方向发展。它基于三个不同的组件:

  • 实体仅仅由其组件集定义。这里实际上没有抽象。

  • 组件纯粹是数据。Health组件只包含生命值;Shield组件只包含护盾数量;Rotation组件只包含对象方向,等等。

  • 系统定义了实体的行为。系统将特定的行为应用于包含特定组件集的每个实体。例如,MoveAndRotateEnemy可能将平移和旋转应用于具有RotationTranslationEnemy组件的每个实体。

现在一切都各就各位。

混合 ECS 和作业

是时候将 ECS 应用到我们 10,000 个旋转的立方体上了。在我们开始之前,我们需要安装以下包:

  1. 打开窗口 | 包管理器。点击高级并确保已启用“显示预览包”。

  2. 然后,从列表中安装“实体”包和“混合渲染器”包:

图片

如前所述,ECS 正在快速发展。我们在这本书中测试了 0.1.1-preview 版本的代码。如果你作为未来的读者,有一个更近的版本,有很大可能会出现一些不兼容性。在这种情况下,我鼓励你将代码与这个官方仓库中包含的最新 ECS 示例进行比较:github.com/Unity-Technologies/EntityComponentSystemSamples。我为我的预测能力不足表示歉意。

  1. 现在我们已经准备好编写第一个组件。我们的立方体需要旋转,因此我们需要一个特定的RotationSpeed。这将是我们的组件名称:
    [Serializable]
    public struct RotationSpeed : IComponentData
    {
        public float Value;
    }

看起来多么简单。正如我们之前所说,组件只是数据。旋转速度由一个单精度浮点数表示;因此,我们只需要存储一个简单的浮点数。

你可能会问:我如何将这个组件附加到实体上?我还能使用检查器来设置值吗?关于我在 Unity 中喜欢的一切好东西呢?遗憾的是,组件不能附加到GameObject(毕竟,GameObject不是 ECS 的一部分)。实体不会出现在场景编辑器中,组件也不会出现在检查器中。

幸运的是,如果我们想保留编辑器的一些功能,比如定义一个可以生成 10,000 次的预制体,那么有一个解决方案。将GameObject-MonoBehaviour范式与 ECS 混合称为混合 ECS,这是保持两者最佳之处的完美方式。

  1. 为了使我们的组件启用,我们需要编写一个IConvertGameObjectToEntity实现。IConvertGameObjectToEntity是一段代码,它自动将标准的MonoBehaviour转换为相应的组件:
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using Unity.Entities;
    using System;
    using Unity.Mathematics;    

    [RequiresEntityConversion]
    public class RotationSpeedAuthoring : MonoBehaviour, IConvertGameObjectToEntity
    {

        public float rotationSpeed = 35f;

        public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
        {
            var data = new RotationSpeed { Value = math.radians(rotationSpeed) }; // Convert to speed in radians
            dstManager.AddComponentData(entity, data);
        }
    }

在前面的代码中,RotationSpeedAuthoring是一个IConvertGameObjectToEntity实现和一个MonoBehaviour(这样我们就可以将其附加到一个GameObject上)。转换的核心在于Convert函数。签名可能有些令人困惑;它过去改变了很多,将来可能还会再次改变。重要的是内容:该函数接受MonoBehaviour的数据,将其添加到一个新的组件(在我们的案例中是RotationSpeed),应用一些处理(在我们的案例中,我们将每秒度转换为每秒弧度),并最终将组件附加到实体上。

  1. 我们现在创建cubePrefab,就像之前一样,并将RotationSpeedAuthoring MonoBehaviour添加到其中,在运行时,GameObject将被转换为实体。

  2. 现在我们已经拥有了所有需要的,我们只需要编写我们的游戏控制器:

using System;
using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;

namespace ECSJob
{
    public class ECSJobManager : MonoBehaviour
    {

        #region COMMON_GAME_MANAGER_DATA
        public float cubeSpacing = 0.1f;
        public int width = 10;
        public int height = 10;

        public GameObject cubePrefab;
        #endregion

        EntityManager entityManager;

        void Start()
        {
            entityManager = World.Active.EntityManager;
            SpawnCubes();
        }

        private void SpawnCubes()
        {
            int amount = Mathf.FloorToInt(width / cubeSpacing) * Mathf.FloorToInt(height / cubeSpacing);
            Debug.Log(String.Format("Spawning {0} cubes", amount));

            Vector3 position = new Vector3();

            var entityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(cubePrefab, World.Active);

            while (position.x < width)
            {
                while (position.y < height)
                {
                    var instance = entityManager.Instantiate(entityPrefab);

                    position = new Vector3(position.x, position.y + cubeSpacing, 0f);
                    entityManager.SetComponentData(instance, new Translation() { Value = position });
                    entityManager.SetComponentData(instance, new RotationSpeed() { Value = math.radians(UnityEngine.Random.Range(25.0f, 50.0f)) });
                }
                position = new Vector3(position.x + cubeSpacing, 0f, 0f);
            }

        }

    }
}

这是一个相当标准的游戏管理器,但让我们来看看激动人心的部分。首先,我们有一个新的属性:entityManager。这只是一个对主要实体管理器的引用。正如其名所示,实体管理器是一个数据结构,你可以对实体执行基本操作,例如检查实体是否仍然存活,或创建和编辑实体。

你不需要创建实体管理器。Unity 会为你提供一个。正如你在 Start 中看到的,你只需要引用主要的全局一个。

  1. 现在是时候生成立方体了。第一行有趣的代码是这一行:
var entityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(cubePrefab, World.Active);

使用这个类,我们将我们构建的预制体转换为实体。预制体中的每个 MonoBehaviour 都会被转换为组件,有时甚至更多。我们已经知道 RotationSpeedAuthoring 被转换为 RotationSpeed,但 Unity 为许多标准的 MonoBehaviour 子类提供了转换,如下所示:

  • 每个 Transform 转换为 TranslationRotationScale 组件(以及一些不太常见的,例如 NonLocalScale

  • 每个 MeshRenderer 转换为 RenderMesh 组件

  1. 现在,对于每个立方体位置,我们需要实例化一个新的实体。这与我们实例化 GameObject 的方式类似,但我们调用 entityManager 上的 Instantiate,如下面的代码块所示:
var instance = entityManager.Instantiate(entityPrefab);
  1. 然后,我们在实体上设置 TranslationRotationSpeed 组件。

第一件事是将立方体的位置设置为计算出的位置,后者设置为随机旋转速度。请注意,该组件使用每秒弧度,因此我们需要转换该值:

entityManager.SetComponentData(instance, new Translation() { Value = position });
entityManager.SetComponentData(instance, new RotationSpeed() { Value = math.radians(UnityEngine.Random.Range(25.0f, 50.0f)) });

到目前为止,我们有了组件,并且我们有了一种实例化实体的方法。我们仍然缺少一个实际移动立方体的系统。我们想要构建一个系统,该系统针对具有 RotationSpeedRotation 组件的每个实体,使它们旋转。不仅如此,我们还想使用 C# 作业,以便所有 10,000 个立方体并行旋转。这是一个典型的模式,因此 Unity 为我们提供了一个类。

然而,我们首先需要编写我们的工作:

public struct RotatorJob : IJobForEach<Rotation, RotationSpeed>
        {

            [ReadOnly]
            public float deltaTime;

            public void Execute(ref Rotation rotation, [ReadOnly] ref RotationSpeed rotationSpeed)
            {
                rotation.Value = math.mul(math.normalize(rotation.Value), quaternion.AxisAngle(math.up(), rotationSpeed.Value * deltaTime));
            }
        }

这与上一个工作类似,但有一些不同。首先,我们扩展了 IJobForEachinstead 而不是 IJobParallelForTransform,因为实体没有 Transforms。你可能注意到我们向 IJobForEach 接口传递了两个类型参数。这些是我们想在作业中使用的组件类型,即 RotationRotationSpeed。我们可以放入任意数量的组件;重要的是我们以与 Execute 参数相同的顺序添加相同的组件。

例如,如果我们扩展IJobForEach<Rotation, RotationSpeed>,那么Execute将接受一个RotationRotationSpeed组件的引用作为参数。然而,如果我们扩展IJobForEach<Scale>,那么Execute将只接受一个Scale组件的引用;等等。这就像对所有实体进行过滤,确保这个作业只应用于包含RotationRotationSpeed组件的实体。

最后,你可能注意到我们正在使用一些奇怪的旋转类型:quaterion,小写字母q。这是因为 Unity 在 ECS 中为向量和四元数开发了一些新的类型,这些类型在作业和组件方面具有更优化的优势。

有很多这样的组件,但像往常一样,它们仍在开发中。要获取它们的最新信息,请查看Unity.Mathematics模块的文档:docs.unity3d.com/Packages/com.unity.mathematics@1.0/manual/index.html

现在我们有了作业,我们需要创建一个利用它的组件系统:

    public class RotationSystem : JobComponentSystem
    {
        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            RotatorJob rotatorJob = new RotatorJob()
            {
                deltaTime = Time.deltaTime
            };

            return rotatorJob.Schedule(this, inputDeps);
        }
    }

JobComponentSystem是一个用于构建可以使用 C#作业运行的系统的类。

我们首先定义一个新的类,称为RotationSystem,它扩展了JobComponentSystem类。在这个类中,我们重写了OnUpdate(注意:OnUpdate,而不是Update)方法,在这个方法中,我们只是创建一个新的RotatorJob作业并安排它。

现在,我们只需将ECSJobManager附加到一个空的GameObject上,运行应用程序,就可以看到所有立方体像往常一样旋转。随着这些更改,我们最终达到了超过 100 FPS!让我们看看 Profiler:

时间过得如此之快,以至于我们可以看到 v-sync 的小峰值。每一帧都少于 10 毫秒;这比我们仅使用经典非 DOTS 方法在脚本上花费的时间还要少!这是一个令人难以置信的加速,而且应用程序的内存不到一半。

但是猜猜看。我们仍然可以做得更好。

爆发式编译器

DOTS 的最后一个组件是爆发式编译器。爆发式编译器是一个可以将 C#的子集编译成优化原生代码的编译器。Burst 的主要目标是编译作业,使它们尽可能快和轻量。

好玩的是,使用爆发式编译器非常简单。首先,你需要从 Window | 包管理器安装 Burst 包。然后,你需要更改的唯一一件事是在作业定义的顶部添加[BurstCompile]装饰器,如下所示:

[BurstCompile]
public struct RotatorJob : IJobForEach<Rotation, RotationSpeed>
        {

            [ReadOnly]
            public float deltaTime;

            public void Execute(ref Rotation rotation, [ReadOnly] ref RotationSpeed rotationSpeed)
            {
                rotation.Value = math.mul(math.normalize(rotation.Value), quaternion.AxisAngle(math.up(), rotationSpeed.Value * deltaTime));
            }
        }

就这样!现在作业是用 Burst 编译的,这将从我们的应用程序中挤出更多性能。我们的演示很简单,Burst 编译的效果有限——在我的机器上,我可以达到 110 FPS,但对于更复杂的作业,影响更为显著。

摘要

DOTS 是 Unity 推动 Unity 进入游戏未来之巅的努力的巅峰。我坚信,在未来,DOTS 将成为任何优化工作的核心组件,并且随着 DOTS 变得更加稳定并得到社区的支撑,这一章节肯定会发展成为几个章节。

不幸的是,在这个阶段,C# 作业和 ECS 仍然非常不稳定,它们的 API 正在迅速变化,因此,我不建议在大型、重要、商业游戏中使用它们。然而,我认为开始尝试它们是很重要的,以便为它们到来做好准备。

本章只是对 DOTS 的表面进行了探讨。还有很多更多的细节、配置和优化可以在作业和 ECS 中实现。更多信息,Unity DOTS 的主要中心(unity.com/dots)是你的最佳朋友。

本章有效地总结了我们可以赋予的所有旨在明确提高应用程序性能的技术。然而,优化你的工作流程也是非常有益的。正如之前提到的,性能优化工作的一个恒定成本是开发时间。但是,如果你能加快我们的开发工作,在工作的更繁琐部分节省一些时间,那么,希望你能节省足够的时间来实际实施我们在这整本书中讨论的尽可能多的优化技术。Unity 引擎中有很多小而巧妙的细节,这些细节并不为人所知或没有明确记录,只有通过使用引擎或参与其社区才能显现出来。因此,下一章将充满关于如何更有效地管理你的项目和场景以及如何充分利用 Unity 编辑器的提示和技巧。

第十章:战术技巧和窍门

软件工程师是一群乐观的人,因此我们经常低估完全实现新功能或修改现有代码库所需的工作量。一个常见的错误是只考虑编写创建该功能所需代码所需的时间。在这种情况下,我们忘记了包括几个重要任务所需的时间。我们经常需要花费时间重构其他子系统以支持我们正在进行的更改。这可能是因为我们没有在当时认为这是必要的,或者是因为我们在中途想到了更好的实现方式,如果没有提前做好规划,这可能会迅速变成一个关于重新设计和重构的兔子洞,如果我们不提前做好规划。

在所有性能优化工作中,唯一恒定的成本就是时间。因此,在我们有限的时间内实现我们的功能和保持一切正常运行,对于任何开发者来说,学习工作流程优化都是一个重要的技能。更好地理解我们所使用的工具,将使我们从长远来看节省更多时间,并希望为我们提供实现我们想要实现的一切所需的多余时间,这不仅适用于 Unity 引擎,也适用于我们使用的每一个工具——集成开发环境(IDEs)、构建系统、分析系统、社交媒体平台、应用商店等等。

在使用 Unity 引擎方面有很多小细节可以帮助改善我们的项目工作流程。然而,编辑器的许多功能并没有得到很好的文档记录,也不是众所周知,或者直到相当长一段时间后我们才会考虑——我们意识到这些功能可以完美地解决我们 6 个月前遇到的一个特定问题。

互联网上充满了博客、推文和论坛帖子,试图帮助其他 Unity 开发者了解这些有用的功能,但它们通常只关注少数几个技巧。似乎没有在线资源将它们集中在一个地方。因此,中级和高级 Unity 开发者的网络浏览器可能因为保存这些技巧的链接而爆满,我们为以后的书签并最终完全忘记。

因此,鉴于这本书主要是为这类用户编写的,我觉得包括一个简短的章节将许多这些技巧和窍门集中在一起放在一个地方是值得的。这一章节作为参考列表,希望在未来开发工作中节省我们时间。

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

  • 编辑器快捷键技巧

  • 编辑器 UI 技巧

  • 脚本技巧

  • 自定义编辑器脚本和菜单技巧

  • 外部技巧

  • 其他技巧

编辑器快捷键技巧

编辑器中充满了可以辅助快速开发的快捷键,查看文档是值得的。然而,让我们说实话——直到需要从手册中获取特定信息之前,没有人会阅读手册。在本节中,我们将介绍一些最实用但不太为人所知的快捷键,这些快捷键在我们使用 Unity 编辑器进行操作时可用。

对于我们每个要讨论的情况,都会列出 Windows 快捷键。如果 macOS 快捷键需要不同的按键组合,那么它将显示在括号中。

与 GameObject 协同工作

可以通过在 Hierarchy 窗口中选择 GameObject 并按Ctrl+Dcmd+D)来复制 GameObject。可以通过按Ctrl+Shift+Ncmd+shift+N)创建新的、空的 GameObject。

Ctrl+Shift+Acmd+shift+A)可以快速打开添加组件菜单。从那里,您可以输入要添加的组件的名称。

Scene 窗口

Shift+F将锁定 Scene 窗口中的对象(假设 Scene 窗口已打开且可见),这对于跟踪高速对象或找出为什么对象可能从我们的场景中掉落非常有用。

在 Scene 窗口中按住Alt并左键单击并拖动鼠标,将使 Scene 窗口的相机围绕当前选定的对象旋转(而不是围绕它查看)。在 Scene 窗口中按住Alt并右键单击并拖动鼠标将放大/缩小相机(Alt+Ctrl+左键拖动)。

按住Ctrl并左键单击并拖动将使所选对象在移动时对齐到网格。同样,可以通过在调整对象周围的旋转控件时按住Ctrl来进行旋转。在 Scene 窗口中,您可以点击网格图标附近的箭头(见以下截图)以打开一个窗口,我们可以根据每个轴编辑对象对齐到的网格:

在 Unity 2020.1 中,对齐到网格的设置已从 Scene 视图工具栏移动到主窗口工具栏(其中包含 Play/Pause/Step 按钮):

我们可以通过在 Scene 窗口中移动对象时按住V键,通过对象的顶点强制对象相互对齐。这样做的话,所选对象将自动将其顶点对齐到最近对象的最近顶点。这对于对齐场景部件,如地板、墙壁、平台和其他基于瓦片的系统非常有用,而无需进行小的手动位置调整。

数组

我们可以通过在 Inspector 窗口中选择它们并按Ctrl+Dcmd+D)来复制已暴露在 Inspector 窗口中的数组元素。这将复制元素并将其立即插入到当前选择之后。

我们可以通过选择元素,右键单击它并选择“删除数组元素”来从引用数组(例如,GameObject 数组)中删除条目。这将删除元素并压缩数组。从原始类型数组(intfloat 等)中删除元素可以通过简单地按下 delete 键实现,无需按住 shift 键 (cmd) 修饰符:

图片

在场景窗口中按住右键鼠标按钮时,我们可以使用 WASD 键以典型的第一人称摄像头控制风格在周围飞行。QE 键也可以用来上下飞行。

界面

我们可以按住 Alt 并单击任何层次结构窗口箭头(任何父对象名称左侧的小灰色箭头)来展开对象的整个层次结构,而不仅仅是层次结构窗口中的下一级。这适用于层次结构窗口中的 GameObject,项目窗口中的文件夹和 Prefabs,检查器窗口中的列表等。

我们可以在层次结构或项目窗口中保存和恢复对象选择,就像典型的 RTS 游戏一样。做出选择并按下 Ctrl + Alt + <0-9> (cmd + alt + <0-9>) 来保存选择。按下 Ctrl + Shift + <0-9> (cmd + shift + <0-9>) 来恢复它。如果我们发现自己反复选择相同的一小批对象进行调整,这会特别有用。您也可以在“编辑”|“选择”中找到保存/加载选择命令:

图片

按下 Shift + 空格键将扩展鼠标光标下的窗口,使其填充整个编辑器屏幕。再次按下它将缩小窗口并恢复到其之前的位置。

按下 Ctrl + Shift + P (cmd + shift + P) 将在播放模式中切换暂停按钮。如果我们急于暂停,通常这个按键组合会比较尴尬,因此创建一个自定义的热键来暂停会很有帮助:

void Update() {
    if (Input.GetKeyDown(KeyCode.P)) {
        Debug.Break();
    }
}

编辑器内文档

我们可以通过在 Visual Studio Community 中突出显示任何 Unity 关键字或类,并按下 Ctrl + '* (cmd + *'**) 来快速访问其文档。这将打开默认浏览器并在 Unity 文档中搜索给定的关键字或类。

注意,使用欧洲键盘的用户可能还需要按住 Shift 键才能使用此功能。

在 Visual Studio 中,可以通过按下 Ctrl + Alt + M,然后按下 Ctrl + H 来实现同样的操作。

编辑器 UI 小贴士

编辑器的默认行为旨在高效且满足每位用户的需求;然而,我们每个人都是不同的,就像美丽的雪花一样,我们的工作偏好也是如此。幸运的是,Unity 允许我们自定义编辑器工作流程的许多方面。让我们通过以下一系列技巧来看看如何实现。

脚本执行顺序

我们可以通过导航到编辑 | 项目设置 | 脚本执行顺序来优先级排序哪些脚本将先于其他脚本调用其 Update()FixedUpdate() 回调。如果我们发现自己试图使用此功能(除时间敏感的系统,如音频处理外)来解决复杂问题,那么这表明我们在组件之间可能存在一些非常脆弱且紧密的耦合。从软件设计的角度来看,这可能是一个警告信号,表明我们可能需要从另一个角度来处理问题。然而,这可以作为快速修复很有帮助。

编辑器文件

将 Unity 项目与源代码控制解决方案集成可能有点棘手。第一步是包含 Unity 为各种资产生成的 .meta 文件;如果我们不这样做,那么任何将数据拉入其本地 Unity 项目的用户都必须重新生成自己的元数据文件。这可能会引起冲突,因此,确保每个人都使用相同的版本是至关重要的。这可以通过导航到编辑 | 项目设置 | 编辑 | 版本控制 | 模式 | 可见元数据文件来实现。

将某些资产数据转换为纯文本格式,而不是二进制数据,也可以很有帮助,以便手动编辑数据文件。这会将许多数据文件转换为更易于人类阅读的 YAML 格式。例如,如果我们使用 ScriptableObjects 来存储自定义数据,我们可以使用文本编辑器来搜索和编辑这些文件,而无需通过 Unity 编辑器和序列化系统来完成所有操作。这可以节省大量时间,尤其是在我们搜索特定数据值或在不同派生类型之间进行多编辑时。此选项可以通过导航到编辑 | 项目设置 | 编辑 | 资产序列化 | 模式 | 强制文本来启用。

编辑器有一个日志文件,可以通过打开控制台窗口(日志消息在此打印),在右上角点击汉堡图标(看起来像三条细的水平线),然后选择打开编辑器日志来访问。这可以帮助我们获取更多关于构建失败的信息。

或者,如果我们成功构建了我们的项目,它将包含所有打包到可执行文件中的资产的压缩文件大小分解,按大小排序。这是一种极有帮助的方法,可以找出哪些资产消耗了我们应用程序的主要空间(提示:几乎总是纹理文件),以及哪些文件占用的空间比我们预期的要多:

图片

通过在现有窗口的标题上右键单击并选择添加选项卡,可以向编辑器添加额外的窗口。这也允许我们添加重复的窗口,例如同时打开多个项目窗口或检查器窗口。这特别有用,可以通过多个项目窗口在不同位置之间移动文件:

图片

如果有重复的检查器窗口,几乎可以说是多余的,因为当我们点击新的对象时,它们会显示完全相同的信息。然而,通过使用锁定图标,我们可以将给定的检查器窗口锁定到当前选择。当我们选择一个对象时,所有检查器窗口都会更新以显示该对象的数据,除了任何已锁定的检查器窗口,它们将继续显示它们被锁定到的对象的数据:

图片

一些利用窗口锁定功能的一些常见技巧包括以下内容:

  • 使用两个相同的窗口(检查器、动画等)并排比较两个对象或轻松地从对象复制数据到另一个对象

  • 观察在 Playmode 期间调整对象时,任何依赖对象会发生什么

  • 在项目窗口中选择多个对象,然后将它们拖放到检查器窗口中的序列化数组中,而不会丢失原始选择

检查器窗口

我们可以将计算输入到数字检查器窗口字段中。例如,在int字段中输入4*128将解析为512,这样我们就不需要拿出计算器或在脑海中计算了。

可以通过右键单击根元素并选择“复制数组元素”或“删除数组元素”来从列表中复制和删除数组元素(与热键类似)。

可以通过点击右上角的小齿轮图标或右键单击组件名称来访问组件的上下文菜单。每个组件的上下文菜单都包含一个重置选项,该选项将所有值重置为其默认状态,这样我们就不必手动重置值。当我们与Transform组件一起工作时,这很有用,因为这个选项将对象的位置和旋转设置为(0,0,0),并将其缩放设置为(1,1,1)

众所周知,如果GameObject是从 Prefab 生成的,则可以使用检查器窗口顶部的“重置”按钮将整个对象重置为其初始 Prefab 状态。然而,不太为人所知的是,可以通过右键单击值的名称并选择“将值重置为 Prefab”来重置单个值。这会恢复所选值,而其余部分保持不变。

“检查器”窗口有一个“调试”模式,可以通过在锁图标旁边的汉堡图标上左键单击并选择“调试”来访问。这将禁用所有来自编辑器脚本的定制“检查器”窗口绘制,并揭示给定GameObject及其组件内的所有原始数据。即使是“私有”数据字段也会变得可见。尽管它们被灰色显示且无法通过“检查器”窗口进行修改,但这仍然为我们提供了一个在“播放模式”期间检查“私有”数据和其它隐藏值的有用方法。“检查器”窗口的“调试”模式还揭示了内部 ObjectID,这在我们在 Unity 的序列化系统中做有趣的事情并想要解决冲突时非常有用。由于在此模式下编辑器脚本也被禁用,因此可以通过比较其内部数据与我们试图在编辑器脚本中揭示的内容来调试此类脚本。

如果我们在“检查器”窗口中有一个数据元素数组序列化,那么它们通常被标记为“元素 N”,其中N代表该元素的数组索引,从0开始。这可能会使得在数组元素是一系列序列化的类或结构时找到特定元素变得有些棘手,因为这些类或结构本身可能有多个子元素。然而,如果对象中的第一个字段是一个字符串,那么元素将被命名为该字符串字段的值:

图片

当选择网格对象时,“检查器”窗口底部的“预览”子部分通常相当小,这使得我们难以查看网格的细节以及它在场景中呈现的样子。然而,如果我们右键单击“预览”子部分的顶部栏,它将被分离并扩展为一个单独的“预览”窗口,这使得我们更容易看到我们的网格。我们不必担心将分离的窗口放回原来的位置,因为如果关闭分离的窗口,那么“预览”子部分将返回到“检查器”窗口的底部。

项目窗口

“项目”窗口的搜索栏允许我们通过点击搜索栏右侧的小图标来过滤特定类型的对象。这提供了一个我们可以通过显示整个项目中该类型所有对象来过滤的不同类型列表。然而,选择这些选项只是将搜索栏填充为t:<type>格式的字符串,这应用了适当的过滤器。

因此,为了提高速度,我们可以在搜索栏中简单地输入等效的字符串。例如,输入t:prefab将过滤出所有预制件,无论它们在层级窗口中的位置如何。同样,t:texture将揭示纹理,t:scene将揭示场景文件,等等。将多个搜索过滤器添加到搜索栏将包括所有类型的对象(它不会揭示仅满足两个过滤器的对象)。这些过滤器是除基于名称的过滤之外的修饰符,因此添加纯文本字符串将通过过滤对象执行基于名称的搜索。例如,t:texture normalmap将找到所有名称中包含normalmap一词的纹理文件。

如果我们正在使用资源包和内置的标签系统,项目窗口的搜索栏也允许我们通过标签使用l:<标签类型>来查找捆绑的对象。

如果一个MonoBehaviour脚本包含序列化的对 Unity 资源的引用(使用[SerializeField]public),例如网格和纹理,那么我们可以直接为脚本分配默认值。在项目窗口中选择脚本文件;检查器窗口应该包含一个用于资产的字段,以便我们可以将默认分配拖放到其中:

图片

默认情况下,项目窗口将文件和文件夹分为两列,并分别处理。如果我们希望项目窗口具有典型的分层文件夹和文件结构,则可以在其上下文菜单(右上角的汉堡图标)中将其设置为单列布局。这在某些编辑器布局中可以节省大量空间。

在项目窗口中右键单击任何对象并选择“选择依赖项”将揭示所有依赖于此资产才能存在的对象,例如纹理、网格和MonoBehaviour脚本文件。对于场景文件,它将列出场景中引用的所有实体。如果我们试图执行资源清理,这将很有帮助。

层级窗口

层级窗口的一个不太为人所知的功能是它能够在当前活动场景中执行基于组件的过滤。这可以通过输入t:<组件名称>来完成。例如,在层级窗口的搜索栏中输入t:light将揭示场景中包含光组件的所有对象。

此功能不区分大小写,但输入的字符串必须与完整的组件名称完全匹配才能完成搜索。从给定类型派生的组件也将被揭示,因此输入t:renderer将揭示所有具有派生组件的对象,例如MeshRendererSkinnedMeshRenderer

场景和游戏窗口

场景窗口相机在游戏窗口中不可见,但通常使用我们之前提到的快捷键移动和放置要容易得多。编辑器允许我们通过导航到“GameObject”|“与视图对齐”或按Ctrl + Shift + Fcmd + shift + F)来对齐所选对象并旋转场景窗口相机。这意味着我们可以使用相机控制将场景窗口相机放置在我们希望对象所在的位置,并通过与相机对齐来放置对象。

类似地,我们可以通过选择“GameObject”|“将视图对齐到所选对象”来将场景窗口相机对齐到所选对象(请注意,在 Windows 或 macOS 上都没有此快捷键)。这对于检查给定对象是否指向正确的方向很有用。

我们可以在场景窗口中执行类似于层次窗口的基于组件的过滤,通过在其搜索栏中使用t:<component>语法。这将导致场景窗口仅渲染包含给定组件(或从它派生的)的对象。请注意,此文本框与层次窗口中的相同文本框链接,因此我们在一个中输入的内容将自动影响另一个,这在搜索难以找到的对象时非常有帮助。

在 Unity 编辑器的最右上角是一个标签为“层”的下拉菜单。它包含场景窗口的基于层的过滤和锁定系统。启用给定层的眼睛图标将显示/隐藏场景窗口中该层的所有对象。切换锁定图标将允许或阻止选择或修改给定层的对象(至少是通过编辑器 UI)。

这在希望防止某人意外选择和移动已经正确放置的背景对象时很有帮助:

编辑器的一个知名且实用的功能是,可以为游戏对象分配特殊的图标或标签,以便在场景窗口中更容易找到它们。这对于没有渲染器但我们希望容易找到的对象尤其有帮助。例如,灯光和相机等对象具有内置的图标,可以在我们的场景窗口中更容易地识别它们。然而,可以通过在游戏窗口右上角点击“ Gizmos”按钮来显示相同的工具。此选项的下拉菜单确定在启用此选项时将可见哪些工具。

播放模式

由于“播放模式”更改不会自动保存,因此修改在“播放模式”期间应用的颜色调色板以使其明显,以便我们知道我们目前正在使用哪种模式是明智的。此值可以通过导航到“编辑”|“首选项”|“颜色”|“播放模式调色板”来设置。

可以通过简单地使用剪贴板从“播放模式”保存更改。如果我们正在“播放模式”中调整对象,并且对其设置感到满意,那么我们可以使用 Ctrl + C (cmd + C) 将对象复制到剪贴板,并在“播放模式”结束后通过 Ctrl + V (cmd + V) 将其粘贴回场景。

在复制对象时应用的所有设置都将被保留。如果我们使用组件上下文菜单中的“复制组件”和“粘贴组件”选项,也可以对整个组件的单独值进行相同的操作。然而,剪贴板一次只能包含一个 GameObject、组件或值的资料。

另一种方法,允许我们在“播放模式”期间保存多个对象的资料,是通过在设置满意后,将它们拖放到运行时的“项目”窗口中创建 Prefabs。如果原始对象是从 Prefab 派生的,并且我们希望更新所有实例,那么我们只需要用新创建的 Prefab 覆盖旧的 Prefab,方法是将复制的副本拖放到原始对象上方。请注意,这也可以在“播放模式”激活时进行,但它可能很危险,因为没有弹出对话框来确认覆盖。务必非常小心,不要覆盖错误的 Prefab。

我们可以使用“帧跳过”按钮(位于编辑器中“暂停”按钮右侧的按钮)逐帧迭代。这可以用来观察逐帧的物理或游戏玩法行为。请记住,这会导致每次迭代调用一个 FixedUpdate 和一个 Update,数量相等,这可能不会反映实际的运行时行为,我们往往对这些回调的调用次数是不相等的。

如果在“播放模式”开始时启用了“暂停”按钮,那么游戏将在第一帧之后立即暂停,给我们一个机会观察在场景初始化期间发生的任何异常。

脚本技巧

如果你是一名开发者,你将花费大量时间编辑代码。当艺术家和设计师在玩彩色图像和视觉效果时,你可能会发现自己被困在黑白代码编辑器领域。有时候编码可能很难,但它不需要枯燥。在以下技巧中,我们将学习如何简化工作中一些最无聊的部分。

通用

我们可以修改新脚本的各个模板,以及着色器和计算着色器文件。如果我们想删除在第二章“脚本策略”中提到的可能导致不必要的运行时开销的空 Update 桩,这可能会很有帮助。这些文件可以在以下位置找到:

  • Windows: <Unity install>\Editor\Data\Resources\ScriptTemplates\

  • macOS: /Applications/Unity/Editor/Data/Resources/ScriptTemplates/

Assert类允许进行基于断言的调试,这对于一些开发者来说可能更舒适,而不是基于异常的调试。有关Assert的更多信息,请参阅 Unity 文档:docs.unity3d.com/ScriptReference/Assertions.Assert.html

属性

属性是非常有用的元级标签,可以赋予 C#中的几乎任何目标。它们通常用于字段和类上,允许我们用特殊属性标记它们,以便它们可以以不同的方式处理。中级和高级 Unity 开发者会发现阅读 C#文档中的属性内容并发挥想象力来创建自己的属性以帮助加速他们的工作流程是值得的。Unity 引擎中内置了许多属性,当在正确位置使用时可以非常有用。

高级用户会注意到,属性也可以赋予枚举、委托、方法、参数、事件、模块,甚至程序集。

变量属性

[Range]属性可以添加到整数或浮点字段中,将其转换为检查器窗口中的滑块。我们可以提供最小值和最大值,从而限制值可以包含的范围。

通常,如果变量被重命名,即使我们通过 IDE 进行重构,那么值也会在 Unity 重新编译MonoBehaviour并对组件的任何实例进行适当更改时丢失。然而,如果我们要重命名之前已序列化的变量,[FormerlySerializedAs]属性非常有帮助,因为它将在编译时将属性中命名的变量的数据复制到指定的变量中。不再因为重命名而丢失数据!

注意,转换完成后,除非变量已被手动更改并重新保存到自属性包含以来的每个相关 Prefab 中,否则移除[FormerlySerializedAs]属性是不安全的。.prefab数据文件仍将包含旧变量名,因此它仍然需要[FormerlySerializedField]属性来确定下次文件加载时应放置数据的位置(例如,当编辑器关闭并重新打开时)。因此,这是一个有用的属性,但过度使用往往会使我们的代码库变得杂乱。

类属性

[SelectionBase]属性将标记组件附加到的任何GameObject作为场景窗口的选择根。如果我们有其他对象的子对象网格,这特别有用,因为我们可能希望第一次点击时选择父对象,而不是带有MeshRenderer组件的对象。

如果我们有一些具有强依赖性的组件,我们可以使用[RequireComponent]属性来强制关卡设计师将关键组件附加到同一个GameObject上。这确保了我们的代码库所依赖的任何依赖项都将由设计师满足,而无需为我们编写大量文档。

[ExecuteInEditMode]属性将强制在编辑模式期间调用对象的Update()OnGUI()OnRenderObject()回调。然而,这里有一些需要注意的事项,如下所示:

  • 只有在场景中发生变化时,例如移动相机或更改对象属性时,才会调用Update()方法。

  • OnGUI()仅在游戏窗口事件期间被调用,而不是其他窗口事件,例如场景窗口

  • OnRenderObject()在场景和游戏窗口的任何重绘事件期间被调用

然而,这个属性为这些对象提供了一组与典型编辑器脚本不同的事件钩子和入口点,因此它仍然有其用途。

日志

我们可以向调试字符串添加丰富的文本标签。例如,<size><b>(粗体)、<i>(斜体)和<color>标签在调试字符串中有效。这可以帮助我们区分不同类型的日志消息,并允许我们突出显示特定元素,如下所示:

Debug.Log ("<color=red>[ERROR]</color>This is a <i>very</i> <size=14><b>specific</b></size> kind of log message");

我们将获得的错误消息如下所示:

图片

MonoBehaviour类有一个方便的print()方法,它与Debug.Log()做同样的事情。

创建一个自定义的日志器类可能会有所帮助,该类会自动将\n\n追加到每个日志消息的末尾。这将推离通常填充控制台窗口的UnityEngine.Debug:Log(Object)杂乱信息。

有用链接

Unity 提供了许多关于各种脚本功能使用的有用教程,这些教程主要针对初学者和中级开发者。这些教程可以在unity3d.com/learn/tutorials/topics/scripting找到。

Unity Answers 上有一篇有用的帖子,提供了涵盖我们在开发过程中可能遇到的大多数不同脚本和编译错误的参考列表。您可以通过在learn.unity.com/上搜索Scripting来找到它。

嵌套协程是一个有趣且有用的脚本领域,但文档并不完善。然而,在处理嵌套协程时,以下这篇虽然老旧但仍然有效的第三方博客文章,涵盖了大量有趣细节,应该被考虑:www.zingweb.com/blog/2013/02/05/unity-coroutine-wrapper

自定义编辑器脚本和菜单提示

虽然众所周知,我们可以在 Editor 脚本中使用 [MenuItem] 属性创建 Editor 菜单项,但一个不太为人所知的能力是能够为菜单项设置自定义快捷键。例如,我们可以通过定义 [MenuItem] 属性以 _k 结尾来使 <q>K</q> 键触发我们的菜单项方法,如下所示:

[MenuItem("My Menu/Menu Item _k")]

我们还可以使用 %#& 字符分别表示 Ctrl (cmd)、ShiftAlt 来包括修饰键。

[MenuItem] 也有两个重载,这使我们能够设置两个额外的参数:一个布尔值,用于确定菜单项是否需要验证方法,以及一个整数,用于确定菜单项在 Hierarchy 窗口中的优先级。

查看 [MenuItems] 的文档,以获取可用的快捷键修饰符、特殊键以及如何创建验证方法的完整列表:docs.unity3d.com/ScriptReference/MenuItem.html

还可以在 Hierarchy 窗口中 ping 一个对象,这与我们在 Inspector 窗口中点击 GameObject 引用并调用 EditorGUIUtility.PingObject() 时发生的情况类似。

Editor 类的原始实现以及大多数人学习如何编写 Editor 脚本的方式,最初涉及在同一个类中编写所有逻辑和内容绘制。然而,PropertyDrawer 类是有效地将 Inspector 窗口绘制委托给主 Editor 类之外的另一个类的方法。这有效地将输入和验证行为与显示行为分离,从而允许对每个字段进行更精细的渲染控制,并更有效地重用代码。我们甚至可以使用 PropertyDrawer 来覆盖内置对象的默认 Unity 绘制,例如 VectorQuaternion

PropertyDrawer 使用 SerializedProperty 类来完成单个字段的序列化,因此在编写 Editor 脚本时应优先使用它们,因为它们利用了内置的撤销、重做和多编辑功能。数据验证可能有点问题,最佳解决方案是在 setter 属性中使用 OnValidate() 调用。Unity Technologies 开发者 Tim Cooper 在 2013 年 Unite 大会上的一个会议详细解释了各种序列化和验证方法的优缺点 (www.youtube.com/watch?v=Ozc_hXzp_KU)。

我们可以使用 [ContextMenu][ContextMenuItem] 属性向组件上下文菜单甚至单个字段的上下文菜单添加条目。这允许我们为我们的组件自定义 Inspector 窗口的行为,而无需编写广泛的 Editor 类或自定义 Inspector 窗口。

高级用户可能会发现,通过AssetImporter.userData变量在 Unity 元数据文件中存储自定义数据很有用。在 Unity 代码库中还有许多利用反射的机会。Ryan Hipple 在 2014 年 Unite 会议上的讨论概述了我们可以在 Unity 编辑器中使用反射的大量巧妙的小技巧和窍门(www.youtube.com/watch?v=SyR4OYZpVqQ)。

外部技巧

以下技巧和窍门与 Unity 编辑器本身之外的主题相关,但可以帮助极大地提高 Unity 开发工作流程。

Twitter 话题标签#unitytips是一个非常有用的 Unity 开发技巧和窍门的资源,实际上,本章中的许多技巧都源于此。然而,标签很难过滤出之前未看到的技巧,而且它往往被用于营销。可以在devdog.io/blog找到这样一个资源,它汇集了来自#unitytips的一周内的一揽子技巧。

如果我们以site:unity3d.com开始搜索 Unity 相关的问题或担忧,可以大大加快搜索速度,这将过滤所有结果,只显示unity3d.com域下的结果。

如果 Unity 编辑器因任何原因崩溃,我们可以通过将以下文件重命名为包含.unity扩展名(对于场景文件)并将其复制到我们的Assets文件夹中来潜在地恢复我们的场景:

\<project folder>\Temp\_EditModeScene

有一个关于游戏编程模式的资源非常出色(或者更确切地说,是以与游戏开发相关的方式解释的典型编程模式),它是完全免费的,并且可在网上找到。以下指南包括关于我们在本书中探索的几个设计模式和游戏功能的信息,例如 Singleton 模式、观察者模式、游戏循环和帧缓冲区加倍:gameprogrammingpatterns.com/contents.html

无论何时发生 Unite 会议,都要关注任何会议视频(或者更好的是,尝试参加它们)。每次会议通常都有几个由 Unity 员工和经验丰富的开发者主持的讨论小组,他们将分享他们能够使用引擎和编辑器完成的许多酷炫和有趣的事情。此外,确保您通过unity3d.com的论坛、Twitter、Reddit、Stack Overflow、Unity Answers 或未来几年出现的任何社交聚会场所参与 Unity 社区。

这本书中包含的每一个技巧都不是凭空想出来的。它最初是一个想法或知识碎片,某人某时某地分享过,最终以某种方式进入了这本书。因此,要跟上最佳技巧、技巧和技术的前沿,最好的方式是保持对 Unity 未来方向的关注,通过参与其社区来保持我们的敏锐度。

其他技巧

最后,本节包含了一些不太适合其他类别的技巧。

使用空 GameObject 组织场景是一个好主意,并将它们用作一组对象的父对象,同时为该组对象命名一个合理的名称。这种方法唯一的缺点是,在位置或旋转更改期间,空对象的变换被包含在内,并且在重新计算时也被包含在内。正如我们所知,将GameObject重新父化到另一个变换有其自身的成本。适当的对象引用、变换更改缓存以及/或使用localPosition/localRotation可以适当地解决一些这些问题。在几乎所有情况下,从场景组织中获得的工作流程的好处远远超过这种微不足道的性能损失。

动画器覆盖控制器(Animator Override Controllers)早在 Unity v4.3 版本中就已经引入,但往往被遗忘或很少被提及。它们是标准动画控制器(Animation Controllers)的替代品,允许我们引用现有的动画控制器,然后覆盖特定的动画状态,以便我们可以使用不同的动画文件。这允许我们拥有更快的流程,因为我们不需要多次复制和调整动画控制器;我们只需要更改少数几个动画状态。

Unity 编辑器的惊人可定制性和其不断增长的功能集意味着有无数的小机会可以改进工作流程,而且每天都有新的发现或发明。资产库市场充满了试图解决现代开发者遇到的各种问题的不同产品,这使得它成为寻找灵感或愿意花钱节省大量麻烦的好地方。

由于这些资产倾向于销售给广泛的受众,这保持了价格低廉,我们可以以极低或无成本获得一些非常实用的工具和脚本。在几乎所有情况下,我们自己开发相同解决方案都需要花费大量的时间。如果我们认为我们的时间是宝贵的,那么偶尔扫描资产库可以是一种非常经济高效的开发方法。

摘要

这本书的内容到此结束。希望你喜欢这次阅读之旅。再次强调,这本书中最重要的建议可能是,在做出任何更改之前,一定要通过基准测试来验证性能瓶颈的来源。我们最不想浪费时间的,就是在代码库中追逐幽灵,而 5 分钟的 Profiler 测试就能为我们节省整整一天的工作。在许多情况下,解决方案需要成本效益分析,以确定我们是否在其他任何领域牺牲了太多,从而增加了进一步的瓶颈。确保你对瓶颈的根本原因有合理的理解,以避免将其他性能指标置于风险之中。还要再次强调这本书的第二条重要建议,即在做出更改后,始终进行性能分析和测试,以确保它们产生了预期的效果。

性能提升全在于问题解决,这可以是一件很有趣的事情,因为由于现代计算机硬件的复杂性,一些小的调整就能带来巨大的回报。有许多技术可以被实施来提高应用程序的性能或加快我们的工作流程。其中一些技术如果没有必要经验、技能和时间来实现,可能很难完全实现。在大多数情况下,如果我们花时间找到并理解问题的根源,这些修复相对简单。所以,大胆地去使用你的知识库,让你的游戏达到最佳状态!

posted @ 2025-10-25 10:34  绝不原创的飞龙  阅读(226)  评论(0)    收藏  举报