OOP ECS与纯ECS


11

首先,我知道这个问题与游戏开发主题相关,但是我决定在这里提出这个问题,因为它实际上归结为一个更一般的软件工程问题。

在过去的一个月中,我阅读了很多有关实体组件系统的信息,现在对这个概念相当满意。但是,似乎有一个方面似乎缺少明确的“定义”,并且不同的文章提出了根本不同的解决方案:

这是ECS是否应该破坏封装的问题。换句话说,其OOP风格的ECS(组件是具有状态和行为的对象,它们封装了特定于它们的数据)与纯ECS(组件是c样式的结构,仅具有公共数据,并且系统提供了功能)。

请注意,我正在开发框架/ API /引擎。因此,目标是无论使用它的人都可以轻松扩展它。这包括添加新类型的渲染或碰撞组件之类的东西。

OOP方法的问题

  • 组件必须访问其他组件的数据。例如,渲染组件的draw方法必须访问变换组件的位置。这将在代码中创建依赖项。

  • 组件可以是多态的,这进一步引入了一些复杂性。例如,可能有一个Sprite渲染组件,它会覆盖渲染组件的虚拟绘制方法。

纯方法的问题

  • 由于必须在某个地方实现多态行为(例如,用于渲染),因此它只是外包给了系统。(例如,精灵渲染系统创建一个继承渲染节点的精灵渲染节点,并将其添加到渲染引擎中)

  • 系统之间的通信可能很难避免。例如,碰撞系统可能需要根据任何具体的渲染组件计算出的边界框。这可以通过让他们通过数据进行通信来解决。但是,这会删除即时更新,因为渲染系统将更新边界框组件,然后碰撞系统将使用它。如果未定义调用系统更新功能的顺序,则可能导致问题。存在一个事件系统,该事件系统允许系统引发其他系统可以订阅其处理程序的事件。但是,这仅适用于告诉系统该怎么做,即无效函数。

  • 还需要其他标志。以图块地图组件为例。它将具有大小,图块大小和索引列表字段。瓦片贴图系统将处理相应的顶点数组,并根据组件的数据分配纹理坐标。然而,每帧重新计算整个图块地图是昂贵的。因此,将需要一个列表来跟踪所有更改,然后在系统中对其进行更新。以OOP方式,这可以由图块地图组件封装。例如,SetTile()方法将在调用顶点数组时对其进行更新。

尽管我看到了纯方法的美妙之处,但我真的不明白与传统OOP相比它将带来什么样的具体好处。组件之间的依赖性仍然存在,尽管被系统隐藏了。同样,我将需要更多的类来实现相同的目标。在我看来,这似乎是一种过度设计的解决方案,但这从来都不是一件好事。

此外,我对性能的关注并不那么深,所以面向数据设计和现金遗漏的整个想法对我来说并不重要。我只想要一个好的架构^^

尽管如此,我阅读的大多数文章和讨论都建议使用第二种方法。为什么?

动画

最后,我想问一个问题:如何在纯ECS中处理动画。目前,我已将动画定义为根据0到1之间的进度操纵实体的函子。动画组件具有一个动画列表,该列表包含一个动画列表。然后,在其更新功能中,它将当前激活的任何动画应用于实体。

注意:

我刚刚读了这篇文章实体组件系统体系结构对象是否按定义导向?可以比我更好地解释问题。尽管基本上是同一主题,但对于纯数据方法为何更好,仍然没有给出任何答案。


1
也许是一个简单而严肃的问题:您是否知道ECS的优点/缺点?这主要解释了“为什么”。
Caramiriel

好吧,我了解使用组件(而不是继承)而不是继承来避免通过多重继承产生死亡的好处。使用组件还可以在运行时操纵行为。而且它们是模块化的。我不明白的是为什么要分割数据和功能。我当前的实现方法在github github.com/AdrianKoch3010/MarsBaseProject上
Adrian Koch,

好吧,我没有足够的ECS经验来添加完整答案。但是合成不只是用来避免DoD。您还可以在运行时创建(独特)实体,这些实体很难使用OO方法生成。也就是说,拆分数据/过程可以使数据更易于推理。您可以轻松地实现序列化,保存状态,撤消/重做以及类似操作。由于它易于推理数据,因此也更容易优化数据。您很可能可以将实体分批(多线程),甚至可以将其卸载到其他硬件上以发挥其全部潜力。
Caramiriel

“可能有一个Sprite渲染组件,它会覆盖渲染组件的虚拟绘制方法。” 我认为,是不是精英了,如果你/需要。
温德拉

Answers:


10

这是困难的一个。我将根据自己的经验(YMMV)尝试解决一些问题:

组件必须访问其他组件的数据。例如,渲染组件的draw方法必须访问变换组件的位置。这将在代码中创建依赖项。

在这里不要低估耦合/依赖的数量和复杂性(而不是程度)。您可能正在看这两者之间的区别(并且此图已经荒谬地简化为类似玩具的水平,而实际示例中将在它们之间使用接口来放松耦合):

在此处输入图片说明

... 和这个:

在此处输入图片说明

... 或这个:

在此处输入图片说明

组件可以是多态的,这进一步引入了一些复杂性。例如,可能有一个Sprite渲染组件,它会覆盖渲染组件的虚拟绘制方法。

所以?vtable和虚拟调度的类比(或文字)等效项可以通过系统调用,而不是通过对象隐藏其基础状态/数据来调用。当类比vtable或函数指针变成系统要调用的各种“数据”时,使用“纯” ECS实现,多态性仍然非常实用和可行。

由于必须在某个地方实现多态行为(例如,用于渲染),因此它只是外包给了系统。(例如,精灵渲染系统创建一个继承渲染节点的精灵渲染节点,并将其添加到渲染引擎中)

所以?我希望这不会成为一种讽刺(虽然我经常被指责,但这不是我的意图,但我希望我可以通过文本更好地传达情绪),但是在这种情况下“外包”多态行为并不一定会引起额外的后果。生产力成本。

系统之间的通信可能很难避免。例如,碰撞系统可能需要根据任何具体的渲染组件计算出的边界框。

这个例子对我来说似乎特别奇怪。我不知道为什么渲染器会将数据输出回场景(在这种情况下,我通常认为渲染器是只读的),或者为什么渲染器要弄清楚AABB而不是其他系统为渲染器和碰撞/物理(我可能在这里挂了“渲染组件”的名字)。但是,我不想对这个示例过于迷恋,因为我意识到这不是您要提出的重点。仍然完全不需要系统之间的通信(即使是间接读取/写入中央ECS数据库,系统之间的通信也直接取决于其他人进行的转换),即使有必要,也不需要经常进行。那'

如果未定义调用系统更新功能的顺序,则可能导致问题。

绝对应该定义。ECS并不是在代码库中重新安排每个可能系统的系统处理评估顺序并将完全相同的结果返回给处理帧和FPS的最终用户的最终解决方案。这是设计ECS时要做的事情之一,我至少强烈建议应该提前预见(尽管以后有很多宽容的喘息空间可以改变主意,只要它不会改变订购的最关键方面)。系统调用/评估)。

然而,每帧重新计算整个图块地图是昂贵的。因此,将需要一个列表来跟踪所有更改,然后在系统中对其进行更新。以OOP方式,这可以由图块地图组件封装。例如,SetTile()方法将在调用顶点数组时对其进行更新。

除了这是一个面向数据的问题外,我不太理解这一点。避免在ECS中表示和存储数据(包括备忘录)也没有陷阱,可以避免此类性能陷阱(具有ECS的最大陷阱往往与诸如系统查询特定组件类型的可用实例之类的事情有关,这是其中之一)。优化广义ECS最具挑战性的方面)。逻辑和数据在“纯” ECS中分离的事实并不意味着您突然不得不重新计算本来可以缓存/存储在OOP表示中的内容。除非我掩饰了非常重要的内容,否则这是有争议/无关紧要的。

使用“纯” ECS,您仍然可以将此数据存储在图块地图组件中。唯一的主要区别是更新此顶点数组的逻辑将转移到某个地方的系统。

如果创建类似的单独组件,您甚至可以依靠ECS来简化从实体中删除该缓存并使其无效TileMapCache。此时,当需要缓存但在具有TileMap组件的实体中不可用缓存时,可以对其进行计算并添加。当它失效或不再需要时,您可以通过ECS删除它,而不必专门编写更多代码用于此类失效和删除。

组件之间的依赖性仍然存在,尽管被系统隐藏了

“纯”代表中的组件之间没有依赖关系(我认为说系统在这里隐藏了依赖关系是不正确的)。可以说,数据不依赖于数据。逻辑取决于逻辑。而且,“纯” ECS倾向于以某种方式促进逻辑的编写,从而取决于系统需要工作的数据和逻辑的绝对最小子集(通常没有),这与许多替代方案不同,后者常常鼓励依赖功能远远超出实际任务所需。如果您使用的是纯粹的ECS权利,那么您应该欣赏的第一件事就是去耦的好处,同时还要对您在OOP中学到的关于封装和特别是信息隐藏的所有知识提出质疑。

通过去耦,我的意思是指系统需要工作的信息很少。您的运动系统甚至不需要了解诸如a Particle或or之类的更复杂的内容Character(系统的开发人员甚至不必知道系统中甚至还存在这样的实体思想)。它只需要了解像位置分量之类的最小数据,就可以像在结构中浮起数一样简单。与像纯接口一样IMotion,它所包含的信息更少,外部依赖更少。这主要是由于每个系统都需要工作,因此对ECS的了解很少,这使得ECS经常宽容地处理事后看来非常出乎意料的设计更改,而不会在各处遇到级联的接口损坏。

您建议的“不纯净”方法在一定程度上会减少收益,因为现在您的逻辑已不严格地局限在变更不会导致级联破坏的系统上。现在,该逻辑将在一定程度上集中在多个系统访问的组件中,这些组件现在必须满足可以使用它的所有各种系统的接口要求,现在好像每个系统都需要了解(取决于)更多知识。严格来说,使用该组件需要的信息。

对数据的依赖性

关于ECS的争议之一是,它倾向于用原始数据替换原本可能是抽象接口的依赖关系,并且通常认为这是一种不太理想且更紧密的耦合形式。但是,在诸如ECS可能非常有益的游戏之类的领域中,通常比在系统的某个中央层级上设计可对数据进行处理的方法更容易地预先设计数据表示并保持其稳定性。即使在代码库中经验丰富的退伍军人中,我也痛苦地观察到了这一点,该代码库更多地使用COM风格的纯接口方法以及诸如此类的东西IMotion

开发人员一直在寻找向此中央接口添加,删除或更改功能的原因,并且每次更改都是可怕且代价高昂的,因为这往往会破坏实现的每个单个类IMotion以及所使用系统中的每个位置IMotion。同时,在整个过程中经历了许多痛苦而又级联的变化,实现的对象IMotion都只是存储了一个4x4的float矩阵,整个接口只关心如何转换和访问这些float;数据表示从一开始就一直是稳定的,如果使用这种集中式界面,就可以避免很多麻烦,因此,由于意料之外的设计需求而导致的更改一开始就不存在。

这听起来几乎像全局变量一样令人恶心,但是ECS如何将这些数据组织到按类型通过系统显式检索的组件中的性质使得它如此,而编译器无法执行诸如信息隐藏,访问和变异的地方之类的操作。数据通常非常明确和明显,足以仍然有效地保持不变性,并预测从一个系统到下一个系统将发生何种类型的转换和副作用(实际上,在某些领域,OOP比某些领域的OOP更简单,更可预测)系统变成扁平的管道)。

在此处输入图片说明

最后,我想问一个问题:如何在纯ECS中处理动画。目前,我已将动画定义为根据0到1之间的进度操纵实体的函子。动画组件具有一个动画列表,该列表包含一个动画列表。然后,在其更新功能中,它将当前激活的任何动画应用于实体。

我们都是实用主义者。即使在gamedev中,您也可能会得到相互矛盾的想法/答案。甚至最纯净的ECS也是一个相对较新的现象,是开创性的领域,为此,人们并不一定就如何剥皮猫提出最强烈的意见。我的直觉反应是一个动画系统,该动画系统会增加动画组件中的这种动画进度,以供渲染系统显示,但是这忽略了特定应用程序和上下文的太多细微差别。

使用ECS并不是灵丹妙药,我仍然发现自己倾向于加入新系统,删除一些系统,添加新组件,更改现有系统以选择该新组件类型,等等。一切都在第一时间就完成了。但是我的情况不同是,当我无法预见某些设计需求时,我不会更改任何核心内容。我并没有得到级联损坏的涟漪效应,这需要我四处走动,并更改大量代码来处理出现的一些新需求,这确实节省了时间。我也发现它对我的大脑更容易,因为当我坐下来使用一个特定的系统时,我不需要了解/记住除相关组件(仅仅是数据)之外的其他任何东西,就可以对其进行操作。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.