我们如何以及为什么编写我们的ECS

上一篇文章中,我描述了开发新型移动快节奏射手时使用的技术和方法。 因为 这是一篇评论,甚至是一篇肤浅的文章-今天,我将深入探讨并详细解释为什么我们决定编写自己的ECS框架而不使用现有框架。 最后会有代码示例和少量奖励。


以ECS为例


我已经简要介绍了什么是实体组件系统,并且在Habré上有关于ECS的文章(不过,基本上是文章的翻译-作为奖励,请参见文章结尾处对我最感兴趣的文章的评论)。 今天,我将通过代码示例告诉您我们如何使用ECS。

上图描述了播放器的本质,其组件及其数据以及与播放器及其组件一起工作的系统。 图中的关键对象是播放器:

  • 可以在空间中移动- 变换运动组件MoveSystem ;
  • 具有一定的健康状况,并可能导致 健康损害损害系统 死亡 ;
  • 死亡后在重生点出现-该位置的Transform组件RespawnSystem
  • 可能是无敌的- 无敌的

我们用代码来描述。 首先,让我们获取组件和系统的接口。 组件可以具有通用的辅助方法,系统只有一个Execute方法,该方法在输入处接收世界状态以进行处理:

public interface IComponent { // < > } public interface ISystem { void Execute(GameState gs); } 

对于组件,我们创建存根类,代码生成器将其用于将它们转换为实际使用的组件代码。 让我们在“ 健康” ,“ 伤害”和“ 无敌”方面有一些空白(对于其余组件,它们将是相似的)。

 [Component] public class Health { [Max(1000)] //  -  1000 public int Hp; // -   public Health(int hp) {} } [Component] public class Damage { [DontSend] //      ,      public uint Amount; // -  public Entity Victim; //    public Entity Source; //    public Damage(uint amount, Entity victim, Entity source) {} } [Component] public class Invincible //   ,  ,    { } 

组件确定世界的状态,因此它们仅包含数据,而没有方法。 同时, Invincible中没有数据,它在逻辑上被用作无敌能力的标志-如果玩家的本质具有这一成分,那么玩家现在就变得无敌了。

生成器使用Component属性来查找组件的空白类。 在进行序列化并减小通过网络传输或保存到磁盘的世界状态的大小时,需要使用MaxDontSend属性作为提示。 在这种情况下,服务器将不会序列化“ 金额”字段并通过网络发送(由于客户端不使用此参数,因此仅在服务器上才需要)。 给定健康状况的最大值, Hp字段可以很好地打包为几位。

我们还有一个Entity预制类,我们在其中添加有关任何实体的所有可能组件的信息,并且生成器已经从中创建了一个真实的类:

 public class Entity { public Health Health; public Damage Damage; public Invincible Invincible; // ... < > } 

之后,我们的生成器将创建HealthDamageInvincible组件类的代码,这些代码已在游戏逻辑中使用:

 public sealed class Health : IComponent { public int Hp; public void Reset() { Hp = default(int); } // ... <  > } public sealed class Damage : IComponent { public int Amount; public Entity Victim; public Entity Source; public void Reset() { Amount = default(int); Victim = default(Entity); Source = default(Entity); } // ... <  > } public sealed class Invincible : IComponent { } 

如您所见,数据保留在类和方法中,例如, Reset 。 需要优化和重用池中的组件。 其他辅助方法不包含业务逻辑-为简洁起见,我将不赘述。

还将为世界状况生成一个类,其中包含所有组件和实体的列表:

 public sealed class GameState { //  public Table<Movement> Movements; public Table<Health> Healths; public Table<Damage> Damages; public Table<Transform> Transforms; public Table<Invincible> Invincibles; //   public Entity CreateEntity() { /* <> */ } public void Copy(GameState gs2) { /* <> */ } public Entity this[uint id] { /* <> */ } // ... <   > } 

最后,为Entity生成的代码:

 public sealed class Entity { public uint Id; //   public GameState GameState; //     //     : public Health Health { get { return GameState.Healths[Id]; } } public Damage Damage { get { return GameState.Damages[Id]; } } public Invincible Invincible { get { return GameState.Invincibles[Id]; } } // …     public Damage AddDamage() { return GameState.Damages.Insert(Id); } public Damage AddDamage(int total, Entity victim, Entity source) { var c = GameState.Damages.Insert(Id); c.Amount = total; c.Victim = victim; c.Source = source; return c; } public void DelDamage() { GameState.Damages.Delete(Id); } // … <     > } 

Entity类实质上只是组件标识符。 对GameState世界的对象的引用仅在辅助方法中使用,以方便编写业务逻辑代码。 了解了组件的标识符后,我们可以使用它来序列化实体之间的关系,在组件中实现与其他实体的链接。 例如, 损害组件包含对受害人实体的引用,以确定谁被损害。

这样就结束了生成的代码。 通常,我们需要一个生成器,以免每次都写辅助方法。 我们仅将组件描述为数据,然后生成器完成所有工作。 辅助方法的示例:

  • 创建/删除实体;
  • 添加/删除/复制组件,如果存在则对其进行访问;
  • 比较世界的两个状态;
  • 序列化世界状况;
  • 增量压缩
  • 网页或Unity窗口的代码,用于显示世界,实体,组件的状态(请参见下面的详细信息);
  • 和其他

让我们继续进行系统代码。 他们定义业务逻辑。 例如,让我们编写一个计算对玩家造成伤害的系统的代码:

 public sealed class DamageSystem : ISystem { void ISystem.Execute(GameState gs) { foreach (var damage in gs.Damages) { var invincible = damage.Victim.Invincible; if (invincible != null) continue; var health = damage.Victim.Health; if (health == null) continue; health.Hp -= damage.Amount; } } } 

该系统遍历了世界上所有的Damage组件,并查看是否有在潜在损坏的播放器( Victim )上存在Invincible组件。 如果他是,则该玩家是无敌的,则不会产生伤害。 接下来,我们获得受害者的健康成分,并通过损害的程度降低玩家的健康。

考虑系统的关键功能:

  1. 系统通常是无状态类,不包含任何内部数据,除了从外部传输的有关世界的数据外,不尝试将其保存在某个地方。
  2. 系统通常会经过某种类型的所有组件并与它们一起工作。 通常通过组件的类型( DamageDamageSystem )或它们执行的操作( RespawnSystem )来调用它们。
  3. 系统实现的功能最少。 例如,如果我们走得更远,在执行DamageSystem之后另一个RemoveDamageSystem将删除所有Damage组件。 在下一个滴答声中,根据玩家的射击情况,另一个ApplyDamageSystem可以再次用新的伤害挂起伤害组件。 然后PlayerDeathSystem将检查播放器的运行状况( Health.Hp ),如果该值小于或等于0,它将销毁除Transform之外的所有播放器组件,并添加Dead Flag组件。

总计,我们获得以下类及其之间的关系:


关于ECS的一些事实


ECS作为一种开发方法和一种代表游戏世界的方式具有其优缺点,因此每个人都可以自己决定是否使用它。 让我们从专家开始:

  • 组合与多重继承。 在多重继承的情况下,可以继承一堆不必要的功能。 对于ECS,添加/删除组件时功能会出现/消失。
  • 逻辑和数据分离。 在不破坏数据的情况下更改逻辑(更改系统,删除/添加组件)的能力。 即 您可以随时禁用负责某些功能的系统组,其他所有功能将继续起作用,并且不会影响数据。
  • 游戏周期得以简化。 出现一个更新 ,并且整个周期分为多个系统。 数据是由系统中的“流”处理的,与引擎无关(与Unity中一样,没有数百万的Update调用)。
  • 实体不知道哪些类会影响它 (也不应该知道)。
  • 有效利用内存 。 这取决于ECS的实施。 您可以使用池来重用创建的实体对象和组件。 您可以将值类型用于数据,并将它们并排存储在内存中( 数据局部性 )。
  • 何时将数据与逻辑分开进行测试更容易 。 尤其是当您认为逻辑是一个包含几行代码的小型系统时。
  • 实时查看和编辑世界状态 。 因为 世界的状态只是数据,我们编写了一个工具,可以在服务器上的比赛中(以及3D中的比赛场景)在网页上显示世界的整个状态。 可以查看,修改,删除任何实体的任何组件。 可以在客户端的Unity编辑器中完成相同的操作。



现在的缺点:

  • 您需要学习以不同的方式思考,设计和编写代码 。 考虑实体,组件和系统。 ECS中的许多设计模式是以完全不同的方式实现的(请参阅末尾的一篇评论文章中的State模式示例实现)。
  • 更多代码 。 值得商。的。 一方面,由于我们将逻辑分解成多个小型系统,因此没有将更多的功能描述在一个类中,而是提供了更多的类,但是没有更多的代码。
  • 系统的调用顺序会影响整个游戏的运行 。 通常,系统相互依赖,它们的执行顺序由列表设置,并按此顺序执行。 例如,首先DamageSystem考虑损坏,然后RemoveDamageSystem除去损坏组件。 如果您不小心更改了订单,那么一切都会有所不同。 通常,如果您更改方法调用的顺序,则对于通常的OOP情况也是如此,但是在ECS中,更容易出错。 例如,如果部分逻辑在客户端上运行以进行预测,则顺序应与服务器上的顺序相同。
  • 我们需要以某种方式将逻辑的数据和事件与视图连接起来 。 对于Unity,我们有MVP:

    -来自ECS的模型-GameState
    -查看-与我们一起这些都是标准的MonoBehavior Unity类( RendererText等)和预制件;
    -Presenter使用GameState确定实体,组件等的出现/消失事件,从预制件创建Unity对象,并根据世界状况的变化对其进行更改。

您知道吗:

  • ECS不仅涉及数据局部性 。 对我来说,这更像是一种编程范例,一种模式,一种设计游戏世界的另一种方式-随便叫什么。 数据局部性只是一种优化。
  • Unity没有ECS! 您经常在团队面试中问候选人-您对ECS有什么了解? 如果您没有听到,请告诉他们,他们会回答:“啊,就像在Unity中一样,我知道!” 但是,不,这与Unity引擎不同。 在那里,数据和逻辑组合在MonoBehaviour组件中,而GameObject (如果与ECS中的实体进行比较)具有其他数据-名称,层次结构中的位置等。Unity开发人员目前正在引擎中正常实施ECS,到目前为止,这似乎还不错。 他们聘请了该领域的专家-我希望结果会很酷。

我们对ECS框架的选择标准


当我们决定在ECS上制作游戏时,我们开始寻找现成的解决方案,并根据一位开发人员的经验写下了对它的要求。 他们描绘了现有解决方案如何满足我们的要求。 在一年前,此刻可能已经发生了变化。 作为解决方案,我们考虑了:


我们整理了一张表格进行比较,其中还包括当前的解决方案(将其指定为ECS(现在) ):


红色-解决方案不支持我们的要求,橙色-部分支持,绿色-完全支持。

对我们来说,在ECS中访问组件和搜索实体的操作类似于在sql数据库中进行的操作。 因此,我们使用了诸如表(table),联接(join operation),索引(indices)等概念。

我们将描述我们的要求以及第三方库和框架在何种程度上与它们相对应:

  • 单独的数据集(历史,当前,视觉,静态) -分别获取和存储世界状态(例如,用于处理,渲染,状态历史等的当前状态)的能力。 考虑的所有决定均支持该要求
  • 实体ID为整数 -支持通过其标识符号表示实体。 通过网络传输以及连接状态历史中的实体的能力是必需的。 没有一种解决方案被认为是受支持的。 例如,在Entitas中,实体由成熟的对象(例如Unity中的GameObject)表示。
  • 通过ID O(N + M)加入 -支持相对快速地采样两种类型的组件。 例如,当您需要获取所有具有“损坏”类型的组件(例如,它们的N个零件)和“运行状况”(M个零件)的所有实体时,以计算并造成损坏。 阿耳emi弥斯得到了全力支持。 在Entitas和Ash.NET中,它比O(N²)快,但比O(N + M)慢。 我现在不记得评估了。
  • 通过ID参考O(N + M)加入 -仅当一个实体的某个组件具有与另一个实体的链接,而后者需要获取另一个组件时才与上述相同(在我们的示例中,辅助实体上的Damage组件指的是玩家实体Victim然后从那里获取“ 健康”组件)。 所考虑的任何解决方案均不支持。
  • 没有查询分配-从全局状态查询组件和实体时,没有额外的内存分配。 在Entitas中,这在某些情况下是不重要的,但对我们而言却微不足道。
  • 池表 - 池中世界数据的存储,重用内存的能力,仅在池为空时才进行分配。 Entitas和Artemis中有“一些”支持,而Ash.NET中则完全没有。
  • 按ID比较(添加,删除) -内置支持按ID创建/销毁实体和组件的事件。 显示级别(视图)必须显示/隐藏对象,播放动画,效果。 所考虑的任何解决方案均不支持。
  • Δ序列化(量化,跳过) -内置的增量压缩,用于序列化世界状态(例如,减少通过网络发送的数据的大小)。 任何解决方案均不支持开箱即用。
  • 插值是世界状态之间的内置插值机制。 没有支持的解决方案。
  • 重用组件类型 -在不同类型的实体中使用一次编写的组件类型的能力。 仅支持Entitas
  • 明确的系统顺序 -设置自己的呼叫顺序系统的能力。 支持所有决定。
  • 编辑器(实体/服务器) -支持实时查看和编辑客户端和服务器实体。 Entitas仅支持在Unity编辑器中查看和编辑实体和组件的功能。
  • 快速复制/替换 -廉价复制/替换数据的能力。 没有支持的解决方案。
  • 组件作为值类型(结构) -组件作为值类型。 原则上,我想基于此实现良好的性能。 不支持单个系统;组件类无处不在。

可选要求( 当时没有任何解决方案支持它们 ):

  • 索引 -索引数据库中的数据。
  • 复合键 -用于快速访问数据的复杂键(例如在数据库中)。
  • 完整性检查 -在世界范围内验证数据完整性的能力。 对于调试很有用。
  • 基于数据本质的知识,基于内容的压缩是最好的数据压缩。 例如,如果我们知道地图的最大尺寸或世界上最大的对象数量。
  • 类型/系统限制 -限制组件或系统的类型数量。 当时在Artemis,不可能创建超过32或64种类型的组件和系统

从表中可以看出,我们自己希望实现除可选需求以外的所有需求。 实际上,目前我们还没有做到:

  • 按ID O(N + M)进行连接,按ID参考O(N + M)进行连接 -对两个不同组件的选择仍然占据O(N²)(实际上是嵌套的for循环)。 另一方面,匹配的实体和组件并不多。
  • 按ID比较(添加,删除) -在框架级别不需要。 我们在MVP的更高级别上实现了此功能。
  • 快速复制/替换组件作为值类型(结构) -在某种程度上,我们意识到使用结构不像使用类那样方便,而是选择使用类-我们更喜欢开发方便而不是更好的性能。 顺便说一下,Entitas开发人员最后也做了同样的事情

同时,我们仍然意识到我们最初认为可选的一项要求:

  • 内容感知压缩 -由于它,我们能够显着(数十次)减小通过网络传输的数据包的大小。 对于移动数据网络,使数据包大小适合MTU非常重要,这样它就不会被“分解”为可能丢失,以不同顺序排列的小零件,然后需要分成几部分组装。 例如,在Photon中,如果数据大小不适合MTU库,则即使您从上方以“不可靠”的方式发送数据,它也会将数据拆分为数据包并以可靠的方式发送(保证传输)。 第一手经过痛苦测试。

ECS发展的特点


  • 我们在ECS专门编写业务逻辑 。 无法使用资源,视图等。 由于ECS逻辑代码同时在Unity中的客户端和服务器上运行,因此它应尽可能独立于其他级别和模块。
  • 我们尝试最小化组件和系统 。 通常,对于每个新任务,我们都会启动新的组件和系统。 但是有时候,我们可能会修改旧版本,将新数据添加到组件中,然后“填充”系统。
  • 在我们的ECS实施中,您不能将多个相同类型的组件添加到一个实体 。 因此,如果玩家在一瞬间被击中几次(例如,多个对手),那么我们通常会为每种伤害创建一个新实体,并向其中添加一个Damage组件。
  • 有时,表示形式不足以显示GameState中的信息 。 然后,您必须添加逻辑中未涉及但视图需要的特殊组件或其他数据。 例如,镜头在服务器上是即时的,一滴答声是有效的,而在视觉上,它在客户端上更长。 因此,对于客户端,将镜头添加到参数“镜头寿命”中。
  • 我们通过创建特殊组件来实现事件/请求 。 例如,如果某个玩家死亡,我们会在其上悬挂一个没有Dead数据的组件,这对于其他系统和该玩家死亡的View级别都是事件。 或者,如果我们需要在该点上再次使玩家复活,则可以使用Respawn组件创建一个单独的实体,其中包含有关复活对象的更多信息。 在游戏周期的一开始,一个单独的RespawnSystem就经历了这些组件,并且已经创造了玩家的精髓。 即 实际上,第一个实体是创建第二个实体的请求。
  • 我们有特殊的“单个”组件/实体 。 例如,我们有一个ID = 1的实体,上面挂有特殊组件-游戏设置。

红利


— ECS — . , , , :

Source: https://habr.com/ru/post/zh-CN413729/


All Articles