我知道,在构建Apple AppStore或Google Play应用商店中的应用程序(本机或Web)时,通常使用Model-View-Controller架构。
但是,使用游戏引擎中常见的组件实体系统架构创建应用程序是否合理?
我知道,在构建Apple AppStore或Google Play应用商店中的应用程序(本机或Web)时,通常使用Model-View-Controller架构。
但是,使用游戏引擎中常见的组件实体系统架构创建应用程序是否合理?
Answers:
但是,使用游戏引擎中常见的组件实体系统架构创建应用程序是否合理?
对我来说,绝对。我从事视觉FX的研究,研究了该领域的各种系统,其体系结构(包括CAD / CAM),对SDK的渴望以及任何可使我对看似无限的体系结构决策的利弊有所了解的论文即使是最微妙的事物也不会总是产生微妙的影响。
VFX与游戏非常相似,因为它有一个“场景”的中心概念,其视口可显示渲染的结果。在动画环境中,也经常有很多中央循环处理围绕此场景进行,其中可能发生物理现象,粒子发射器生成粒子,对动画进行渲染和渲染的网格,运动动画等,最终渲染它们最后全部交给用户。
至少与非常复杂的游戏引擎类似的另一个概念是需要一个“设计者”方面,设计师可以灵活地设计场景,包括自己进行一些轻量级编程(脚本和节点)的能力。
多年来,我发现ECS最适合。当然,这从来没有完全脱离主观性,但是我要说,它似乎带来了最少的问题。它解决了我们一直在努力解决的许多主要问题,而只给了我们一些新的未成年人的回报。
如果您牢牢把握了设计需求,而不是实施需求,那么更传统的OOP方法将非常强大。无论是通过扁平化的多接口方法还是在更嵌套的层次化ABC方法中,它都趋于巩固设计并使其更难更改,同时使实现更容易,更安全地进行更改。超过单个版本的任何产品都始终存在不稳定的需求,因此OOP方法倾向于使稳定性(变化的难度和缺乏变化的原因)趋向于设计水平,以及不稳定的状态(易于变化和变化的原因)到实施水平。
但是,针对不断发展的用户端要求,可能需要经常更改设计和实现。您可能会发现一些奇怪的事情,例如强烈要求用户端同时需要植物和动物的类比生物,从而完全破坏了您构建的整个概念模型。普通的面向对象方法在这里并不能保护您,有时可能会使这种意料之外的,破坏概念的更改更加困难。当涉及性能非常关键的区域时,设计更改的原因会进一步增加。
组合多个细粒度接口以形成对象的合规接口可以在很大程度上稳定客户端代码,但是在稳定子类型方面却无济于事,这有时会使客户端依赖项的数量相形见war。例如,您可以让一个接口仅被系统的一部分使用,但可以使用一千种不同的子类型来实现该接口。在那种情况下,维护复杂的子类型(之所以复杂是因为它们要履行许多不同的接口职责)可能会成为噩梦,而不是代码通过接口使用它们。OOP倾向于将复杂性转移到对象级别,而ECS则将复杂性转移到客户端(“系统”)级别,当系统很少,但有一堆合格的“对象”(“实体”)时,这是理想的选择。
一个类还私有地拥有其数据,因此可以独自维护所有不变式。尽管如此,当对象之间进行交互时,实际上仍然很难维护某些“粗糙”的不变式。为了使一个复杂的系统整体处于有效状态,即使适当地维护了它们的各个不变量,通常也需要考虑对象的复杂图。传统的OOP风格方法可以帮助维护粒度不变式,但是如果对象集中在系统的微小方面,则实际上可能很难维护宽泛的粗糙不变式。
在这种情况下,此类构建乐高积木的ECS方法或变体可能会非常有用。同样,由于系统的设计比通常的对象要粗糙,因此在系统的鸟瞰图上维护这些类型的粗糙不变量变得更加容易。许多小对象交互变成了一个专注于一项广泛任务的大型系统,而不是小对象专注于具有覆盖一公里纸张的依赖图的小任务。
尽管我一直是面向数据的心态之一,但我还是不得不向游戏行业以外的领域学习ECS。而且,很有趣的是,我自己反复尝试并尝试提出更好的设计,就差一点就走向了ECS。我并没有完全做到这一点,而错过了一个非常关键的细节,那就是“系统”部分的形式化,并将组件压缩到原始数据。
我将尝试介绍如何最终解决ECS,以及最终如何解决以前的设计迭代中的所有问题。我认为这将有助于突出说明为什么答案可能是非常肯定的“是”,因为ECS的潜在应用范围远远超出游戏行业。
我在VFX行业工作的第一个架构具有很长的历史,自我加入公司以来已经过去了十年。一直以来都是蛮力的C编码(我并不喜欢C,但对C偏爱,但是在这里使用它的方式确实很粗糙)。微型且过于简单的切片类似于如下所示的依赖项:
这是系统的一小部分的极大简化图。图中的每个客户端(“渲染”,“物理”,“运动”)都将获得一些“通用”对象,通过它们它们可以检查类型字段,如下所示:
void transform(struct Object* obj, const float mat[16])
{
switch (obj->type)
{
case camera:
// cast to camera and do something with camera fields
break;
case light:
// cast to light and do something with light fields
break;
...
}
}
当然,要比这复杂得多且更复杂的代码。通常,会从这些切换案例中调用其他功能,这些功能将递归地一次又一次地进行切换。此图和代码几乎看起来像ECS-精简版,但没有强大的实体部分的区别(“ 是该对象的相机?”,而不是‘没有这个对象提供运动?’),并没有‘系统’的形式化(只是一堆嵌套函数遍布各处并混淆了职责)。在这种情况下,几乎所有事情都很复杂,任何功能都有可能导致灾难等待发生。
我们这里的测试程序经常必须检查像网格物体之类的东西与其他类型的物体是否分开,即使两者都发生了相同的事情,因为这里编码的强力本质(通常伴随着大量的复制和粘贴)从一个项目类型到下一个项目类型,完全相同的逻辑很可能会失败。即使有强烈表达的用户端需求,尝试扩展系统以处理新类型的物品也几乎是没有希望的,因为当我们为处理现有类型的物品而付出很多努力时,这太困难了。
一些优点:
一些缺点:
视觉特效行业的大多数人都使用我收集的这种架构风格,阅读有关其设计决策的文档并浏览其软件开发套件。
在ABI级别上,它可能不完全是COM(这些体系结构中的某些只能使用相同的编译器编写插件),但是与对对象进行接口查询以查看其组件支持的接口具有很多相似的特性。
通过这种方法,transform
上面的类比功能变得类似于这种形式:
void transform(Object obj, const Matrix& mat)
{
// Wrapper that performs an interface query to see if the
// object implements the IMotion interface.
MotionRef motion(obj);
// If the object supported the IMotion interface:
if (motion.valid())
{
// Transform the item through the IMotion interface.
motion->transform(mat);
...
}
}
这是旧代码库的新团队所采用的方法,最终得以重构。在灵活性和可维护性方面,这是对原始版本的显着改进,但是在下一节中,我仍然会涉及一些问题。
一些优点:
一些缺点:
IMotion
将始终具有完全相同的状态和完全相同的实现。为了减轻这种情况,我们将开始集中整个系统中的基类和辅助功能,以便针对相同的接口以相同的方式冗余地实现这些事情,并且可能在幕后进行了多个继承,但这确实很不错。即使客户端代码很简单,也很混乱。QueryInterface
功能,几乎总是显示为中上热点,有时甚至出现在#1热点上。为了减轻这种情况,我们会做一些事情,例如让代码库的渲染部分缓存已知支持的对象列表IRenderable
,但这极大地提高了复杂性和维护成本。同样,这更难以衡量,但是与之前每个接口都需要动态分配的C风格编码相比,我们注意到了一定的放慢速度。诸如分支错误预测和优化障碍之类的事情很难在代码的一小部分之外进行衡量,但是用户通常只是注意到用户界面的响应能力,而诸如此类的事情则是通过并行比较旧版本和较新版本的软件而变得更糟,对于算法复杂度不变的区域,只有常数。我们之前(或者至少在我之前)注意到的引起问题的一件事IMotion
可能是由100个不同的类实现,但是它们具有完全相同的实现和状态。此外,它只能由少数几个系统使用,例如渲染,关键帧运动和物理。
因此,在这种情况下,我们可能会喜欢使用接口接口的系统之间的3对1关系,以及实现接口接口的子类型之间的100对1关系。
然后,复杂性和维护将大大偏向于100个子类型的实现和维护,而不是依赖于3个客户端系统IMotion
。这将我们所有的维护困难转移到了这100个子类型的维护上,而不是使用界面的3个地方。使用很少或没有“间接传出耦合”来更新代码中的3个地方(就像依赖于它,但通过接口间接地,不是直接依赖),没什么大不了的:用一堆“间接传出耦合”来更新100个子类型的地方,相当重要*。
* 我意识到从实现的角度来理解“高效耦合”的定义是很奇怪和错误的,我只是没有找到更好的方法来描述当接口和相应的一百个子类型的实现相关联的维护复杂性必须改变。
因此,我不得不努力,但我建议我们尝试变得更加实用一些,并放松整个“纯界面”的想法。对我来说,使诸如IMotion
完全抽象和无状态之类的东西变得毫无意义,除非我们看到它具有多种实现方式对它有好处。在我们的案例中,IMotion
拥有各种各样的实现实际上会变成维护方面的噩梦,因为我们不想多样化。取而代之的是,我们反复尝试制作一个对改变客户需求确实非常有用的单个运动实现,并且经常围绕纯接口思想工作,试图迫使每个实现者IMotion
使用相同的实现和相关的状态,以便我们不这样做。重复目标。
因此,接口变得更像Behaviors
与实体相关联的广泛对象。IMotion
会简单地变成一个Motion
“组件”(我将我们从COM定义“组件”的方式更改为更接近通常定义的组成“完整”实体的组件)。
代替这个:
class IMotion
{
public:
virtual ~IMotion() {}
virtual void transform(const Matrix& mat) = 0;
...
};
我们将其演变为以下形式:
class Motion
{
public:
void transform(const Matrix& mat)
{
...
}
...
private:
Matrix transformation;
...
};
这是对依赖反转原则的公然违反,它开始从抽象转向具体,但是对我来说,这样的抽象水平仅在我们能够在合理的怀疑范围内预见到未来真正的需求时才有用。为了实现这种灵活性,请使用完全脱离用户体验的荒谬的“假设情况”场景(无论如何都可能需要更改设计)。
因此,我们开始发展这种设计。QueryInterface
变得更像QueryBehavior
。此外,在这里使用继承似乎毫无意义。我们改用合成。对象变成了一组组件,这些组件的可用性可以在运行时查询和注入。
一些优点:
Motion
实现,可以更轻松地解决诸如需求变更或工作流投诉之类的不可预见的意外情况,例如,这些意外情况不会分散在一百个子类型中。一些缺点:
发生的一种现象是,由于我们失去了对这些行为组件的抽象,因此我们拥有了更多的行为。例如,IRenderable
我们用一个具体的Mesh
或PointSprites
组件代替一个对象,而不是一个抽象的组件。渲染系统将知道如何渲染Mesh
和PointSprites
组件,并将找到提供此类组件的实体并进行绘制。在其他时候,我们有其他可渲染对象,就像SceneLabel
事后发现我们需要的那样,因此SceneLabel
在这种情况下,我们会将a附加到相关实体(可能除了之外Mesh
)。然后,将更新渲染系统工具,以了解如何渲染提供实体的实体,这很容易进行更改。
在这种情况下,由组件组成的实体也可以用作另一个实体的组件。我们将通过连接乐高积木来构建事物。
就我个人而言,最后一个系统是我自己制作的,而我们仍在使用COM对其进行混用。感觉好像是想成为一个实体组件系统,但当时我还不熟悉它。当我本来应该从AAA游戏引擎中汲取建筑灵感时,我一直在寻找COM风格的示例,这些示例使我的研究领域更加充实。我终于开始这样做了。
我所缺少的是几个关键思想:
我最终离开了那家公司,开始作为印品的ECS(仍在努力工作,同时耗尽了我的积蓄),到目前为止,它是最容易管理的系统。
我使用ECS方法注意到的是,它解决了我仍然在上面挣扎的问题。对我而言最重要的是,感觉就像我们在管理大小健康的“城市”,而不是管理互动复杂的小村庄。它并不像一个庞大的整体大城市那样难以维护,人口众多而无法有效地进行管理,但它并不像一个充满小村庄的世界那样混乱,它们只是在相互思考贸易路线。他们之间形成了一个噩梦图。ECS将所有复杂性提炼为庞大的“系统”,例如渲染系统,健康大小的“城市”,而不是“人口过剩的特大城市”。
起初,成为原始数据的组件真的让我感到很奇怪,因为它甚至破坏了OOP的基本信息隐藏原理。这是我对OOP所珍视的最大价值之一,这是一种挑战,它是保持不变性的能力,而不变性要求封装和信息隐藏。但是,它变得无关紧要,因为很快就很明显,只有十几个广泛的系统正在转换数据,而不是将这样的逻辑分散在数百个成千上万个实现接口组合的子类型中,从而发生了什么。除了在系统提供访问数据的功能和实现,组件在提供数据,实体在提供组件的地方扩展之外,我倾向于以OOP样式的方式来考虑它。
当只有少数笨重的系统以大范围转换数据时,就更容易,反直觉地推断由系统引起的副作用。系统变得非常“扁平”,每个线程的调用堆栈变得比以往任何时候都更浅。我可以在监督者级别上考虑该系统,而不会遇到奇怪的惊喜。
同样,就消除那些查询而言,它甚至使性能至关重要的区域也变得简单。由于“系统”的概念变得非常形式化,因此系统可以订阅其感兴趣的组件,而只是交给满足该条件的实体的缓存列表。每个人都不必管理该缓存优化,而是将其集中到一个地方。
一些优点:
一些缺点:
但是,使用游戏引擎中常见的组件实体系统架构创建应用程序是否合理?
因此,无论如何,我个人肯定会说“是”,我的VFX示例就是一个不错的选择。但这仍然与游戏需求十分相似。
我没有在完全远离游戏引擎关注的更偏远地区进行实践(VFX十分相似),但是在我看来,更多的地区是ECS方法的良好候选者。甚至一个GUI系统都可能适合,但是我仍然在那儿使用更多的OOP方法(例如,与Qt不同,它没有深度继承)。
这是一个尚未开发的领域,但是,只要您的实体可以由丰富的“特征”组合(以及它们所提供的特征组合可能随时发生变化)组成,并且您可以将其归纳为少数,这对我来说似乎很合适处理具有必要特征的实体的系统。
在任何情况下,如果您可能想使用多重继承或概念模拟(例如混合),仅在深度继承层次结构中产生数百个或更多连击或数百个连击,那么它成为一种非常实用的选择平面层次结构中的类,实现特定的接口组合,但是您的系统数量很少(例如数十个)。
在那些情况下,代码库的复杂性开始与系统的数量而不是类型组合的数量成比例,因为每种类型现在只是一个组成组件的实体,这些组件不过是原始数据。GUI系统自然适合这些规格,其中它们可能具有数百种可能的控件类型与其他基本类型或接口组合,但是只有少数几个系统可以处理它们(布局系统,渲染系统等)。如果GUI系统使用ECS,则当少数几个系统提供所有功能而不是具有继承的接口或基类的数百种不同的对象类型提供所有功能时,就可以更轻松地推断系统的正确性。如果GUI系统使用ECS,则小部件将没有功能,只有数据。只有少数处理窗口小部件实体的系统才具有功能。如何处理小部件的可覆盖事件超出了我的范围,但是仅根据我到目前为止的有限经验,我还没有发现无法以某种方式将这种类型的逻辑集中传输到给定系统的情况。后见之明,产生了我所期望的更加优雅的解决方案。
我很想看到它在更多领域中的应用,因为它是我的救星。如果您的设计没有以这种方式分解,那当然是不合适的,从实体聚集组件到处理这些组件的粗糙系统,但是如果它们自然地适合这种模型,那是我迄今为止遇到的最美妙的事情。
当然,如果问题域非常适合它。
我当前的工作涉及一个需要根据一系列运行时因素来支持多种功能的应用程序。使用基于组件的实体将所有这些功能解耦,并允许隔离性中的可扩展性和可测试性对我们来说是田园般的。
编辑: 我的工作涉及提供与专有硬件(在C#中)的连接。根据硬件的外形尺寸,安装的固件,客户端购买的服务级别等,我们需要为设备提供不同级别的功能。根据设备的版本,甚至具有相同接口的某些功能也具有不同的实现。
这里以前的代码库具有非常广泛的接口,其中许多未实现。一些接口具有许多瘦接口,然后将它们静态地组合成一个类。一些简单地使用string-> string字典对其进行建模。(我们有很多部门都认为他们可以做得更好)
这些都有其不足之处。宽界面是有效模拟/测试的一个痛苦。添加新功能意味着更改公共接口(以及所有现有的实现)。许多瘦接口导致代码使用起来非常丑陋,但是自从我们最终通过一个庞大的胖对象测试以来,仍然遭受了麻烦。另外,瘦接口不能很好地管理其依赖关系。字符串字典具有通常的解析和存在问题,以及性能,可读性和可维护性方面的难题。
我们现在使用的是一个非常苗条的实体,它的组件是根据运行时信息发现并组成的。依赖关系是由声明完成的,并由核心组件框架自动解决。组件本身可以单独进行测试,因为它们可以直接使用其依赖项,并且缺少依赖项的问题可以尽早发现-在一个位置而不是第一次使用依赖项。可以插入新的(或测试)组件,并且不会影响现有的代码。消费者要求实体提供组件的接口,因此我们可以相对自由地随意修改各种实现(以及如何将实现映射到运行时数据)。
对于这样的情况,其中对象及其接口的组合物可以包括常用的组件的一些(高度变化)的子集,它工作非常好。