嗨,黑客努恩!我选择的下一个主题是 中的ECS(实体组件系统)。我将其分为两部分,以帮助您更容易地理解所有信息。 Unity开发 我将告诉您我所知道的有关实体组件系统的一切,并尝试消除对此方法的各种先入之见。您会发现很多关于 ECS 的优点和缺点、这种方法的特殊性、如何与它交朋友、潜在的陷阱和有用的实践的文字。我还将简要介绍一下适用于 Unity/C# 的 ECS 框架。 本文对于那些想要/开始熟悉 ECS 的人来说将会很有帮助。我希望尝试过 ECS 的人也能够为自己强调一些新的东西。如果您使用 C# 以外的任何语言制作游戏,您可能仍然会发现本文很有用。不会有代码示例和模式历史,只有我的经验、推理和观察:) 什么是 ECS(实体组件系统)? 实体组件系统是专门为游戏开发创建的架构模式。它非常适合描述动态的虚拟世界。由于它的特殊性,有些人认为它几乎是一种新的编程范式。 ECS是组合优于继承的绝对原则。它可以是面向数据设计 (DOD) 的特定示例,但它取决于特定实现对模式的解释。 让我们来解读一下这个模式的名称: ——最抽象的对象。它是定义该实体的属性的条件容器。它通常表示为访问数据的标识符。 实体 - 具有对象数据的属性。 ECS 中的组件应该只包含纯数据,没有任何逻辑。然而,一些开发人员允许在组件中使用各种 getter 和 setter。尽管如此,我认为静态实用程序更适合这些目的。 组件 ——数据处理的逻辑。 ECS中的系统不应包含任何数据,而应仅包含数据处理逻辑。但是,同样,一些开发人员允许它定义系统的一些辅助行为,例如常量或各种辅助服务。 系统 从上面您已经意识到:ECS 严格分离数据和逻辑。对象的行为不是由接口/合约/公共 API 决定,正如我们在经典的面向对象编程 (OOP) 中所习惯的那样,而是由分配给对象的属性决定,数据 + 处理逻辑单独存在。 在 ECS 中,数据决定一切。这是它区别于其他开发方法的主要属性:一切都是数据。对象属性、特征和事件只是 ECS 世界中的数据。逻辑只是所有这些数据的管道处理。 为什么需要 ECS? 您可能已经有一个疑问:“为什么需要 ECS?它有什么用?”。为了帮助您决定是否进一步阅读本文,我将告诉您为什么我喜欢 ECS。 就我个人而言,我喜欢 ECS 是因为: 使用 ECS,您只需坐下来 而不用与项目的架构进行斗争。没有必要建立庞大而美丽的层次结构,考虑许多联系,并担心“X 不应该知道 Y”。同时,当进一步的项目开发变得非常痛苦时,ECS 原则可以保护您(当然不是 100%)免受不良架构造成的绝望局面。而且即使出现问题,在 ECS 中重构也不是问题。在我看来,这是 ECS 最好的地方。 在 Unity 中制作游戏, ECS上的代码简单明了。您无需通过类之间的调用来了解特定系统的功能。您可以立即看到所有内容,特别是如果您将功能拆分为系统,将系统拆分为方法,并且不要使代码过于复杂。此外,ECS 极大地简化了分析过程。您可以立即看到哪个逻辑(系统)花费了多少帧时间。您无需在调用深度中寻找延迟的根源。 操纵逻辑是毫不费力的。添加新逻辑实际上是轻松的。您只需在正确的位置插入一个新系统,而不必担心直接影响其余代码(应该注意的是,通过数据间接影响是可能的)。您可以在客户端和服务器之间使用通用逻辑(系统),而不会出现任何问题,同时保留所使用的数据(组件)。您可以轻松地重写系统,用重构的系统替换旧系统,而不会影响其余代码。如果您不喜欢这个结果,只需重新打开旧系统即可。相同的机制可以轻松组织 A/B 测试。 一切都围绕数据。事实证明它非常方便。通过直接操作实体上的数据,组合学的可能性是巨大的。您可以使用数据将实体塑造成任何东西。假设该框架提供了用于查看实体数据的工具。在这种情况下,您可以检查任何实体上的数据及其动态,而无需运行调试器来查看内存。 现在你明白我的意思了吗? 如何使用ECS? 这里我用最简单的例子来简单的描述一下ECS的开发过程是如何进行的。我将尽可能抽象地完成此操作,而不参考编程语言。如果您已经有一些 ECS 经验,您可以直接进入下一部分:) 创建一个沿给定运动矢量方向移动的对象。 任务: 首先,让我们定义工作所需的数据。对于我们的任务,我们需要对象的位置和给定的运动矢量。在 ECS 语言中,这些将是: PositionComponent 用于存储位置向量 运动向量的 MovementComponent 下一步是描述逻辑。让我们创建一个 。在系统的main方法中,根据实现的不同,可以是 或者其他。您将获取 ECS 中具有 和 的所有实体。具体如何完成取决于框架,但通常它看起来像一种 SQL 查询,如 。 MovementSystem Run()/Execute()/Update() PositionComponent MovementComponent GetAllEntities().With<PositionComponent>().With<MovementComponent>() 最后,您只需使用我们的两个组件创建一个实体(甚至十个),并将运动矢量设置为不同于零。现在,在每次调用 时(无论我们在何时何地调用它),我们的对象都会在给定运动矢量的方向上改变位置。任务完成! :) MovementSystem 通常,系统以某种方式嵌入到项目的 GameLoop 中,并由引擎本身抽动每一帧。但您可以手动或任何其他方式完成,因为它只是一个方法调用。 让我们看看除了解决主要问题之外,我们还获得了哪些额外的开发可能性: 我们的任何其他系统都可以通过简单地检查 MovementComponent 属性是否存在来确定对象是否正在移动 任何其他系统都可以获得其需要的运动矢量 我们的任何其他系统都能够随意为我们的任何实体指定运动矢量 如果我们愿意,我们还可以通过简单地将 和 放置在任何其他实体上来使其移动。这在 时非常有用。 PositionComponent MovementComponent 创建 Unity 游戏 Unity 中 ECS 的优点 在本节中,我们将讨论 ECS 的优点和缺点。下面描述的一些功能具有硬币的两面。它们既有利于发展,也有令人不舒服的地方,从而产生了有时必须规避的限制。首先我们来讨论一下ECS在Unity中的优势。 代码内聚力弱 这对 来说是一个有益的特性。它使我们能够相对轻松地重构和扩展代码库,并且不会破坏旧代码。我们总是可以使用旧数据添加新行为,而不会以任何方式干扰旧逻辑。 ECS之所以能达到这样的效果,是因为数据表达了Entity中的所有逻辑交互。这是一个没有任何保证的最大抽象对象,就像 C#/Java 中的一些对象一样。 Unity 游戏开发者 但是,您应该记住,在 ECS 中,数据更改的顺序起着重要作用。它最终可能会影响重构的复杂性并破坏旧的逻辑,甚至产生令人不快的副作用错误。 完美的逻辑模块化和可测试性 如果所有交互都用纯数据来表达,那么我们的逻辑总是与数据源完全解耦。这使我们能够将逻辑从一个项目移动到另一个项目并重用它(当然,同时保留数据格式),以及在任何输入数据上运行逻辑以测试其操作。 写出糟糕的代码更难 ECS 对架构的要求较低,因为它设置的框架更难以创建非常糟糕的代码设计。同时,如上所述,即使确实发生了错误的代码设计,我们也可以相对轻松地解决问题,并且对其余代码的影响最小。 ECS 让我们更少地思考“如何在不破坏任何东西的情况下将这种逻辑融入到我们的架构中”并添加新功能。 性质组合学 这一优势使 ECS 成为描述动态世界的绝佳选择。试想一下:您可以毫无麻烦地将任何属性(以及逻辑)赋予您的任何实体! 如果你想让相机有健康,你可以在相机上放置一个 。它会受到损坏(如果有这样的系统)。将 放在实体上,如果该实体具有 ,则该实体会立即开始因燃烧而受到伤害。你想让房子在玩家的控制下移动吗?没问题,只需使用 即可。 HealthComponent InFireComponent HealthComponent PlayerInputListenerComponent 经验丰富的开发人员会说:“哈,大多数基于继承的组合模式都可以处理这个问题。ECS 哪里更好?”。我的答案是:“ECS 不仅允许您在实体形成方面组合属性,而且还可以在同一实体上组合多个属性(组件)时创建特定的逻辑。” 我什至没有提到在不触及实体组件的情况下为旧数据添加全新逻辑的能力! 执行单一职责更容易 当我们的逻辑完全与数据分离并且不依赖于任何对象/实体时,通过其目的而不是其在层次结构中的位置来控制逻辑的分区变得更容易。每个系统仅执行一些其独有的特定任务。通常,系统代码看起来像是对同一类型的许多组件的单个方法调用。因此,代码大多易于阅读和理解。 更清晰的分析 在分析时,我们可以看到它的逻辑以及需要多少帧时间。这要归功于独立的系统及其负责处理的逻辑。我们不需要深入调用堆栈来了解什么花费了最多的时间。我们立刻就能看到有罪的CharMovementSystem。 需要注意的是,这个优势取决于ECS框架设备,因为框架本身可能有它的调用堆栈。 ECS 可以带来良好的性能提升 很多人认为性能好是ECS的主要优势(感谢Unity的宣传)。这并不完全正确。代码执行速度是该模式原理带来的一个不错的奖励:数据在一个位置 - 逻辑在另一个位置 + SIMD(单指令,多数据)。如果框架在实现 ECS 时遵循 DOD 并实现良好的数据局部性,我们还可以获得缓存友好的代码,这将使您的处理器满意。 最终的 ECS 性能取决于许多因素:框架如何准确存储数据、框架如何过滤实体、系统访问数据的速度以及系统内代码的运行速度。 然而, ,ECS总是比通常的MonoBehaviour方法更快,尤其是在大量数据上。但不要忘记,对游戏性能而言,重要的不是架构模式,而是算法的复杂性和您编写的代码的性能。 在Unity开发的背景下 数据处理更容易并行化 由于逻辑被分离到一个单独的数据处理器中,并且数据实际上是一个线性序列,因此我们可以在一个系统内并行处理而不会出现任何问题。如果系统同时处理大量实体并且它们不以任何方式相互交叉,那么这一点非常重要。 您可以更进一步,将不与更改的数据重叠的逻辑发送到不同的线程。然而,控制和监控却要困难得多。尽管如此,与主线程同步准备数据仍然会存在瓶颈。此外,线程之间的数据准备和分发的开销可能会高于系统中的代码执行时间。因此,您需要评估它是否值得。 干净的数据非常容易使用 在几乎每个 Unity 游戏中,我们都必须保存、加载或序列化某些内容以通过网络发送。当数据与逻辑分离时,这会容易得多。无需思考“这应该如何进入私有数据......”并调用一些特殊方法来进行正确的序列化。您只需在实体上保存/加载必要的组件即可。然后,如果系统认为有必要,就会将其完成到所需的状态。 您可以根据需要频繁更改 ECS 框架 ECS 框架彼此相似,因为原理相同。为 ECS 重建大脑并很好地理解一个框架的开发人员可以毫无问题地使用另一个 ECS 框架。学习 API 和特定框架的特性只需要时间。但无需为新方法重新思考。 Unity 中 ECS 的缺点 正如您所看到的,Unity 中的 ECS 比其他模式具有许多宝贵的优势。现在我们来讨论一下Unity中ECS的缺点。 对于经验丰富的 Unity 开发人员来说门槛较高 虽然 ECS 概念可以用一句话来描述,但学习正确使用它需要大量的练习。 ECS 要求您忘记以前对设计所了解的一切:所有垂直继承层次结构、对象的行为由其接口决定、对象是具体且不可变的东西、对象可以拥有私有空间以及逻辑可以随心所欲地被调用。 在ECS中,一切都不是这样的。这与上面描述的相反。在这里,所有数据都是开放的,所有实体都是抽象的并且非常动态,它们的属性都在一个平面上并且每个人都可以访问,逻辑按照传送带原理工作,并且实体的行为通常会根据数据动态变化。 代码内聚力弱可能是一个问题 假设您突然需要两个具体实体(例如,履带体和坦克炮塔)之间的紧密交互。在这种情况下,您将面临实体是抽象的问题,并且您无法在编译器级别保证毛毛虫主体将位于另一端。 这会造成阻碍,因为 Unity 游戏是一个有很多密切交互的地方,并且您总是希望有一个具有属性和行为保证的直接引用。您必须检查组件是否存在并以某种方式处理其不存在,从实体访问组件以开始与其交互等。 从任何地方访问任何数据 ECS 世界是一个开放的实体盒,其中的数据可供所有组件使用。与上面的弱代码内聚一样,这对于 ECS 来说既是优点也是缺点。 一方面,它非常方便。您不必弄清楚如何绕过设计过程中早期创建的自我限制框架(“X 必须不知道 Y”)并将以前隐藏的数据公开给公众来解决一些迫在眉睫的问题。 另一方面,任何缺乏经验的程序员都会尝试从不应该更改的地方更改数据。但通常情况下,团队合作涉及信任他人的工作,因此信任但要验证;) 系统完全在流程中工作,一个接一个 当正确遵循 ECS 原则时,您不应该在另一个系统中调用一个系统的逻辑。系统根本不应该知道彼此的存在。否则,它将导致不必要的代码内聚并可能损害您的项目。然而,这种限制可能会带来不便,有时会导致各种不违反 ECS 原则的解决方法。如果你现在仍然需要调用一些代码,只需用方法创建一个常规对象并将其放入组件中即可,不要折磨自己。 不适用于递归逻辑 这个缺点是前一个缺点的结果。由于缺乏在线程外部以及我们想要的任何地方调用系统代码的能力,ECS 几乎不可能在任何特定系统之外创建递归代码。 作为解决这个缺点的方法(也称为遵守 ECS 原则的解决方法),我只能建议您创建一个专门的结构/系统,只要满足特定条件,它就会无限循环地调用特定的系统列表。我的意思是,只要有带有 DoActionComponent 的实体。如果您有更优雅的解决方法,我很乐意在评论中阅读它们:) 系统执行顺序至关重要 在 ECS 中,了解和控制系统如何更改数据至关重要。通常可能会错过某些系统对我们正在处理的数据的影响,并最终导致各种意外的副作用。顺便说一句,跟踪它们可能很复杂(这是下一个缺点)。然而,在编写系统时,通常可以以这样的方式设计它们:系统的调用顺序并不重要。 更难调试 这是一个颇具争议的观点,尤其是对于现代智能 IDE。由于缺乏深度 StackTrace(我们的系统中有不与实体绑定的逻辑)并且无法跟踪数据和实体状态如何以及由谁更改,因此查找系统的原因可能具有挑战性突然无法按预期方式工作。尽管有人只是向实体添加了一个组件或进行了额外的 ++,但理解导致特定调用的原因并不容易。 总而言之,在 ECS 中,如果没有调试工具,就很难跟踪组件中的数据发生变化的原因和方式,尤其是当您有数千个实体而只有一个有问题的实体时。这可以通过框架提供的调试工具来解决。但它们可能无法开箱即用,您必须自己编写它们,否则就会受苦。 对于数据结构,尤其是分层结构来说,这是一个糟糕的选择 使用 ECS 实现数据结构很困难、不方便,而且在我看来毫无意义。我并不是说这是不可能的(如果你足够努力,一切皆有可能),但这将是一条布满荆棘的道路,到最后不会有太多好处,所以请理性选择。 我将列出一些在尝试在 ECS 上实现某些数据结构时会干扰的问题: 在 ECS 中,所有数据都可以从任何地方访问。对于需要最大一致性的数据结构来说,这可能是极其危险的。任何路过的“鳄鱼”都可以改变任何内部数据来绕过你的逻辑,彻底破坏你的数据结构。 如果我们诚实地遵循 ECS 原则,我们就无法像使用数据结构时通常需要的那样此时此地调用数据结构的逻辑。然而,这一点可以通过静态实用程序/扩展来解决。 ECS是水平架构的代表。其中的所有数据都位于一个平面上,几乎总是一维组件数组。如果您的数据结构需要垂直/层次结构,这会变得很困难。 数据结构需要元素(层次结构)之间的交叉引用并不罕见。但是,您可能还记得,一切都围绕 ECS 中的最大抽象实体展开。这使得工作变得具有挑战性,因为无法保证另一端存在我们需要的类型的元素。因此,必须单独处理。 数据结构及其元素通常不需要在运行时改变数据格式,也不需要组合。他们非常僵化。每个数据结构实体最终可能只有一个组件。 假设您仍然需要一个数据结构。在这种情况下,我建议您使用方法将其创建为单独的对象,然后将该对象放入您的组件中,然后像往常一样从系统中使用它。 更多文件和类 在 中,项目中的文件数量比经典方法中的类似代码增长得更快。至少因为您拥有两个类,而不是具有数据和逻辑的 1 个类:组件和系统(您仍然可以将它们隐藏在一个文件中)。最多,如果你让所有组件都是原子的(1个组件 - 1个字段),将会有非常非常多的文件...... ECS 方法 样板代码 这个缺点很大程度上取决于ECS框架的具体实现。在某些框架中,你必须编写大量的技术代码。在其他方面,开发人员试图使 API 尽可能简单并尽量减少样板文件。但是,如果将其与其他方法进行比较,几乎总是需要编写少量的附加代码。我的意思是声明组件、获取包含必要组件的过滤器、从中获取实体、从实体获取组件等。 小结论 第 1 部分到此结束。在第二部分中,我将讨论: ECS 中的新手错误 ECS 中的良好实践 在 Unity/C# 中使用 ECS 的框架 如果您有任何疑问,请在评论中留下!