Nomad游戏引擎:第2部分-ECS

继承下来!

这篇文章是系列文章的一部分,我在其中记录我从头开始构建ECS游戏引擎的经验。 请查看 该项目 主页,以 获取更多帖子,信息和源代码。

实体组件系统

正如我在上一篇文章中提到的那样,我将要开始制作的游戏引擎将遵循ECS(实体组件系统)方法。 在这篇博客文章中,我将尽我所能尽可能简单地解释我对ECS的实施。 人们创造了许多很棒的资源,比我解释ECS的要聪明得多,因此您可能想知道为什么我甚至不愿意为此发表文章。

  1. 如果我要撰写有关此引擎的博客系列,我认为最好是采用整体方法,而不是仅链接到其他人关于该主题的帖子。
  2. 实际上,有很多实施ECS引擎的方法,它们之间的差异非常大。 通过发布此帖子,我为以后的帖子奠定了基础。

实体和组件

ECS遵循组成优先于继承的原则。 下面的示例应该可以说明这个概念,但是如果您对此感到好奇,我强烈建议您观看此视频,因为它可以很好地解释为什么继承的重要性很重要。

在Nomad中,我们有实体,组件和系统(ECS)。 为了解释这些概念,我将使用以下示例:

在此示例中,我们有三个实体或游戏对象-玩家,日志和球体。 这是游戏的要求:

  1. 播放器由箭头键控制
  2. 玩家和宝珠都具有生命值(可能会受到伤害)
  3. 玩家无法浏览原木(但球体可以漂浮在原木上)

在ECS体系结构中,根据实体具有的属性为其分配组件。 我们可以深入研究以上需求,以发现我们有7个基本组成部分:

这似乎使很多组件变得不必要地复杂,但我们可以看到,每个组件实际上都是很小的功能,这使概念化变得更加容易。 通过这些组件,让我们看一下我们的实体:

这是ECS的本质:实体只是提供功能的组件的集合。 正确完成后,可以添加和删除组件以添加或删除功能。 例如,如果我也想让球体与原木和播放器相撞,我可以简单地向其中添加一个“碰撞”组件。 如果玩家有隐形斗篷,我可以简单地删除其“ Sprite”组件。 直观吧?

数据驱动型ECS

好的,所以我们有一堆分配了组件的实体。 这实际上如何在幕后运作? 这是Nomad变得稍微复杂的地方。 让我们看看Nomad认为实体是什么:

 结构实体{ 
unsigned int id;
}

是的,没错。 这只是一个ID。 那意味着没有功能,没有实现。 只是数据。 如何看一下我们的组件之一:

  struct HealthComponent { 
int currentHealth;
int maxHealth;
}

再一次,没有功能,只有数据。 有了这些新知识,我应该阐明我们的组成部分:

您会注意到,这些组件现在都不具备任何功能,它们只是数据包。

因此,此时您可能有两个主要问题:

  1. 实体和组件如何联系在一起?
  2. 实际的代码(功能)在哪里?

组件经理

第一个问题的答案实际上非常简单。 组件管理器管理一种类型的所有组件,并保留对其拥有实体的引用。 这是在Nomad中实际组织数据的方式:

赋予组件自己的管理者而不是让实体拥有组件似乎是一个任意决定,但是这样做实际上会大大提高性能。 暂时,让我们深入了解这两个选项的内存布局:

这里最重要的信息是处理器喜欢对连续数据数组进行迭代。 我们在计算机内存中跳动的次数越少越好。

让我们用一个例子来说明为什么右侧在性能方面要好得多。 我们的玩家(和他值得信赖的同伴球)正在与一个boss战斗,boss决定投掷一枚炸弹,使战斗期间该地区每个人的生命降低20%。

伪代码如下所示:

  foreach(被炸弹袭击的实体): 
HealthComponent hp =实体.getHealth();
hp.maxHealth = hp.maxHealth * 0.8;

如果我们的实体拥有自己的组件,我们将在内存中从“玩家”实体的内存跳转到“天体”实体的内存。 在此循环中,我们将顺序访问随机内存位置,这是不理想的。 但是,如果我们将组件连续地保存在内存中,我们将按顺序访问处理器喜欢的数据数组 显然,此示例仅是两个组件,但是为了进行论证,让我们考虑一个游戏可能具有给定类型的数百个组件。 在内存中跳转以更新其最大运行状况与仅通过数组运行之间的性能差异是相当大的。

系统篇

好了,因此我们已经以合理的深度介绍了实体和组件。 我们实际上如何添加功能? 游戏代码去哪儿了?

答案是“系统”。 实体和组件只是数据容器,而系统是实际修改该数据的系统。 在Nomad中,系统可以指定希望注意的一组组件类型。 任何具有必要组件的组件都将由系统更新。 这听起来可能令人困惑,但是在举个例子之后应该更有意义。

运动系统

运动系统是最基本和必要的系统之一。 如果您看一下上面列出的组件,您会注意到我们同时具有Transform和Motion组件。 这是它们的外观(请注意,在游戏代码中它们看上去有些不同,但这应该可以用来说明概念):

  struct Transform { 
int x;
诠释
}
结构运动{
Vec2速度;
Vec2加速度;
}

运动系统负责在每个游戏滴答时更新所有实体的位置和速度。 因此,运动系统声明它要注意同时具有“变换”和“运动”组件的任何实体。 随着组件的添加和删除,移动系统注意的实体列表将发生变化。 每次更新时,运动系统将运行以下内容:

 无效更新(int dt){ 
for(m_entities中的实体){
TransformComponent位置= entity.getTransform();
MotionComponent motion = entity.getMotion();
  position.x + = motion.velocity.x; 
position.y + = motion.velocity.y;
  motion.velocity.x + = motion.acceleration.x; 
motion.velocity.y + = motion.acceleration.y;
}
}

再次让我们考虑一下如何在此update()函数中遍历内存。

请注意此图表的一些重要事项:

  1. 我们实际上并没有访问每个“转换”组件。 这是因为2号实体(日志)没有运动组件,所以“运动”系统没有注意它。
  2. 即使我们跳过了第二个“转换”组件,我们的内存访问仍然使用数据数组,这可以极大地提高性能。 随着我们添加移动的更多实体,性能收益将继续增加。

回到我们的例子

让我们看一下将原始示例变为现实所需的系统(再一次,请记住,系统只会关注具有*所有*必需组件的实体):

基于这些系统,我们可以看到玩家实体将成为运动,玩家输入,碰撞和渲染的一部分。 日志实体将是Collision系统和Render系统的一部分,而orb实体将是Movement,Follow和Render系统的一部分。 请注意,对于机敏的人来说,碰撞系统通常也需要考虑运动,但是在此示例中我们将其省略。

添加新功能很简单,只需根据需要添加组件和系统。 由于系统是独立的,并且仅处理特定的组件子集,因此游戏引擎的耦合度非常低,这使得调试和计划变得更加容易。 此外,大多数系统实际上不需要按特定顺序运行,因此我们可以让不同的系统同时在不同的线程上执行,从而大大提高了性能。

以下是一些其他实现,它们可能与我的略有不同,但在解释ECS方面做得很好:

基于组件的引擎设计
基于组件的引擎设计最初是为了避免令人讨厌的继承类的类层次结构而开创的 。www.randygaul.net 牛仔编程
用组件重构游戏实体直到最近几年,游戏程序员一直在使用深层次的… Cowboyprogramming.com GameDev.net社区论坛
实现游戏实体的传统方法是使用面向对象的编程。 每个实体都是一个对象,其中包括:

目前的进展

您看到的大多数框都是用于调试的(冲突的边界框等)。 自从我上一篇文章以来有几处变化:

  • 碰撞检测现在使用空间哈希(黑色正方形)
  • 现在按z顺序绘制精灵(这就是玩家可以在树的后面和前面奔跑的原因)
  • 增加了剑砍的能力
  • 向“变换”组件添加了旋转(火球和剑斜线都使用它)

在接下来的几周中,请留意我在系列中的下一篇文章!