如何在基于组件的实体系统中正确实现消息处理?


30

我正在实现一个具有以下内容的实体系统变体:

  • 一个实体类是多一点的ID结合部件一起

  • 一堆没有“组件逻辑” 的组件类,只有数据

  • 一堆系统类(又名“子系统”,“管理器”)。这些完成所有实体逻辑处理。在大多数基本情况下,系统只是遍历它们感兴趣的实体列表并对其进行操作

  • MessageChannel类对象被所有的游戏系统共享。每个系统都可以订阅特定类型的消息以进行收听,还可以使用该频道将消息广播到其他系统

系统消息处理的初始变体是这样的:

  1. 依次在每个游戏系统上运行更新
  2. 如果系统对某个组件执行了某些操作,而其他系统可能对此操作感兴趣,则该系统会发送一条适当的消息(例如,系统调用

    messageChannel.Broadcast(new EntityMovedMessage(entity, oldPosition, newPosition))

    每当实体移动时)

  3. 订阅特定消息的每个系统都会得到其消息处理方法,称为

  4. 如果系统正在处理事件,并且事件处理逻辑要求广播另一条消息,则立即广播该消息,并调用另一条消息处理方法链

在我开始优化碰撞检测系统之前,这个变体是可以的(随着实体数量的增加,它变慢了)。首先,它只是使用简单的蛮力算法迭代每个实体对。然后,我添加了一个“空间索引”,该索引具有一个单元格网格,该网格存储特定单元格区域内的实体,因此仅允许对相邻单元格中的实体进行检查。

实体每次移动时,碰撞系统都会检查该实体是否与新位置的物体发生碰撞。如果是,则检测到冲突。并且,如果两个碰撞的实体都是“物理对象”(它们都具有RigidBody分量,并且打算彼此推开以免占据相同的空间),则专用的刚体分离系统会要求运动系统将实体移动到某个将他们分开的特定职位。这进而导致运动系统发送消息,通知已更改的实体位置。碰撞检测系统旨在做出反应,因为它需要更新其空间索引。

在某些情况下,这会引起问题,因为单元的内容(C#中的Entity对象的通用列表)在进行迭代时会被修改,从而导致迭代器抛出异常。

那么... 如何防止碰撞系统在检查碰撞时被打断?

当然,我可以添加一些“聪明” /“棘手”逻辑来确保正确地迭代单元格内容,但是我认为问题不在于碰撞系统本身(在其他系统中也存在类似问题),而是消息在系统之间传播时得到处理。我需要某种方式来确保特定的事件处理方法能够正常工作,而不会造成任何干扰。

我尝试过的

  • 传入消息队列。每当某个系统广播消息时,该消息就会被添加到对该消息感兴趣的系统的消息队列中。当每帧调用系统更新时,将处理这些消息。问题是:如果系统A将消息添加到系统B的队列中,则如果系统B的更新要晚于系统A(在同一游戏框架中),则它工作良好;否则,它将导致该消息处理下一个游戏框架(某些系统不希望使用)
  • 传出邮件队列。当系统处理事件时,它将广播的所有消息添加到传出消息队列中。消息无需等待系统更新被处理:在初始消息处理程序完成工作后,它们将立即得到处理。如果对消息的处理导致其他消息被广播,它们也将添加到传出队列中,因此所有消息都在同一帧中进行处理。问题:如果实体生存期系统(我用一个系统实现了实体生存期管理)创建了一个实体,它将通知某些系统A和B。当系统A处理该消息时,它将导致一系列消息,这些消息最终导致所创建的实体被破坏(例如,在与某个障碍物碰撞的位置处创建了一个项目符号实体,这会导致项目符号自毁)。在解析消息链时,系统B没有获得实体创建消息。因此,如果系统B也对实体破坏消息感兴趣,它将得到它,并且只有在“链”完成解析之后,它才会得到初始实体创建消息。这将导致销毁消息被忽略,创建消息被“接受”,

编辑-回答问题,评论:

  • 碰撞系统迭代时,谁修改了单元格的内容?

当碰撞系统正在对某个实体及其邻居进行碰撞检查时,可能会检测到碰撞,并且该实体系统将发送一条消息,其他系统立即对此进行响应。对消息的反应可能导致其他消息被创建并立即处理。因此,即使先前的碰撞检查尚未完成,其他一些系统也可能会创建一条消息,表明碰撞系统随后需要立即进行处理(例如,移动了一个实体,因此碰撞系统需要更新其空间索引)。

  • 您不能使用全局传出消息队列吗?

我最近尝试了一个全局队列。它引起新的问题。问题:我将储罐实体移动到墙实体(储罐由键盘控制)。然后我决定改变坦克的方向。为了将水箱和墙壁的每个框架分开,CollidingRigidBodySeparationSystem将水箱从墙壁上移开的最小可能量。分离方向应与水箱的移动方向相反(游戏图纸开始时,水箱应看起来好像从未移入墙壁)。但是方向变成了与新方向相反的方向,因此将储罐移动到与最初不同的另一侧。发生问题的原因:这是现在处理消息的方式(简化代码):

public void Update(int deltaTime)
{   
    m_messageQueue.Enqueue(new TimePassedMessage(deltaTime));
    while (m_messageQueue.Count > 0)
    {
        Message message = m_messageQueue.Dequeue();
        this.Broadcast(message);
    }
}

private void Broadcast(Message message)
{       
    if (m_messageListenersByMessageType.ContainsKey(message.GetType()))
    {
        // NOTE: all IMessageListener objects here are systems.
        List<IMessageListener> messageListeners = m_messageListenersByMessageType[message.GetType()];
        foreach (IMessageListener listener in messageListeners)
        {
            listener.ReceiveMessage(message);
        }
    }
}

代码流如下(假设它不是第一个游戏框架):

  1. 系统开始处理TimePassedMessage
  2. InputHandingSystem将按键转换为实体动作(在这种情况下,向左箭头变为MoveWest动作)。实体动作存储在ActionExecutor组件中
  3. ActionExecutionSystem响应实体动作,在消息队列的末尾添加了MovementDirectionChangeRequestedMessage。
  4. MovementSystem根据Velocity组件数据移动实体位置,并将PositionChangedMessage消息添加到队列末尾。使用上一帧的移动方向/速度来完成移动(比方说北)
  5. 系统停止处理TimePassedMessage
  6. 系统开始处理MovementDirectionChangeRequestedMessage
  7. MovementSystem根据要求更改实体速度/运动方向
  8. 系统停止处理MovementDirectionChangeRequestedMessage
  9. 系统开始处理PositionChangedMessage
  10. CollisionDetectionSystem检测到由于一个实体移动,它遇到了另一个实体(坦克进入墙内)。它将CollisionOccuredMessage添加到队列
  11. 系统停止处理PositionChangedMessage
  12. 系统开始处理CollisionOccuredMessage
  13. CollidingRigidBodySeparationSystem通过将水箱和墙壁分开来对碰撞做出反应。由于墙是静态的,因此只有水箱可以移动。坦克的移动方向被用作指示坦克来自何处。方向相反

BUG:坦克移动该框架时,它使用的运动方向是前一个框架的运动,但是当分离时,即使它已经不同,也使用了THIS框架的运动方向。那不是它应该如何工作的!

为避免此错误,需要将旧的运动方​​向保存在某个地方。我可以将其添加到某个组件中只是为了修复此特定的错误,但是这种情况是否表示处理消息的根本错误的方式?分离系统为什么要关心它使用哪个运动方向?我该如何优雅地解决这个问题?

  • 您可能想阅读gamadu.com/artemis,以了解他们对Aspects所做的工作,这些方面解决了您所遇到的一些问题。

实际上,我已经对Artemis熟悉了一段时间了。研究了它的源代码,阅读了论坛等等。但是我看到“方面”仅在几个地方被提及,据我所知,它们基本上是指“系统”。但是我看不到阿尔emi弥斯如何解决我的一些问题。它甚至不使用消息。

  • 另请参阅:“实体通信:消息队列与发布/订阅与信号/插槽”

我已经阅读了有关实体系统的所有gamedev.stackexchange问​​题。这似乎并没有讨论我面临的问题。我想念什么吗?

  • 以不同的方式处理这两种情况,更新网格不需要依赖运动消息,因为它是碰撞系统的一部分

我不确定你是什么意思。CollisionDetectionSystem的较早实现只在更新时检查冲突(当处理了TimePassedMessage时),但是由于性能,我不得不尽可能地减少检查。因此,当一个实体移动时(我游戏中的大多数实体都是静态的),我切换到了碰撞检查。


我有些不清楚。碰撞系统迭代时,谁修改了单元格的内容?
保罗·曼塔

您不能使用全局传出消息队列吗?因此,系统完成后每次都会发送其中的所有消息,这包括系统自毁。
罗伊(Roy T.)

如果要保留这种复杂的设计,则必须遵循@RoyT。的建议,这是处理序列问题的唯一方法(没有复杂的,基于时间的消息传递)。您可能想阅读gamadu.com/artemis,以了解他们对Aspects所做的工作,这些方面解决了您所遇到的一些问题。
Patrick Hughes


2
您可能想通过下载CTP并编译一些代码来学习Axum的工作方式,然后使用ILSpy将结果反向工程为C#。消息传递是参与者模型语言的重要功能,而且我确信Microsoft知道它们在做什么-因此您可能会发现它们具有“最佳”实现。
乔纳森·迪金森

Answers:


12

您可能已经听说过God / Blob对象反模式。好吧,你的问题是上帝/斑点循环。修补您的消息传递系统最多只能提供一个创可贴解决方案,而最糟糕的是完全浪费时间。实际上,您的问题与游戏开发完全无关。我发现自己尝试修改集合时要对其进行多次迭代,而解决方案始终是相同的:细分,细分,细分。

据我了解您问题的措辞,您目前更新碰撞系统的方法大致如下。

for each possible collision
    check for collision
    handle collision
    modify collision world to reflect change // exception happens here

如此写得很清楚,您可以看到您的循环只应承担三个职责。为了解决你的问题,分裂您电流回路为代表三个不同的算法三个独立的回路通行证

for each possible collision
    check for collision, record it if a collision occurs

for each found collision
    handle collision, record the collision response (delete object, ignore, etc.)

for each collision response
    modify collision world according to response

通过将原始循环细分为三个子循环,您不再需要尝试修改当前正在迭代的集合。还要注意,您没有比原始循环做更多的工作,实际上,通过顺序执行多次相同的操作,您可能会获得一些缓存优势。

还有另一个好处,就是您现在可以在代码中引入并行性。您的组合循环方法本质上是串行的(从根本上讲,这是并发修改异常告诉您的!),因为每次循环迭代都可能会读取和写入冲突世界。但是,我在上面介绍的三个子循环都可以读取或写入,但不能同时读取和写入。至少,检查所有可能的冲突的第一遍变得令人尴尬地变得并行,并且取决于您编写代码的方式,第二遍和第三遍也可能如此。


我完全同意这种说法。我在游戏中使用了非常相似的方法,并且相信从长远来看,这种方法会有所回报。这就是碰撞系统(或管理器)应该如何工作的(我实际上认为完全没有消息传递系统是可能的)。
Emiliano

11

如何在基于组件的实体系统中正确实现消息处理?

我想说的是,您需要两种类型的消息:同步消息和异步消息。同步消息将立即处理,而异步消息则不在同一堆栈框架中处理(但可以在同一游戏框架中处理)。通常基于“每个消息类别”做出决定,例如“所有EnemyDied消息都是异步的”。

有些事件刚刚处理太多太多有以下几种方式之一容易。例如,根据我的经验,与ObjectWillBeDeletedAtEndOfFrame相比,ObjectGetsDeletedNow-事件的性感度要低得多,并且回调的实现要困难得多。再说一次,任何类似于“否决权”的消息处理程序(可以在执行某些操作时取消或修改某些动作的代码,例如Shield-effect修改DamageEvent)在异步环境中并不容易,但是同步呼叫。

在某些情况下,异步可能更有效(例如,以后无论如何删除对象时,您都可以跳过某些事件处理程序)。有时,同步更为有效,尤其是在为事件计算参数的成本很高时,您宁愿传递回调函数来检索某些参数,而不是已经计算出的值(以防万一对此特定参数不感兴趣的人)。

您已经提到了仅同步消息系统的另一个普遍问题:以我对同步消息系统的经验来看,大多数错误和悲伤情况通常是在迭代这些列表时更改列表。

考虑一下:由于同步(立即处理某些操作的所有后效应)和消息系统(将接收方与发送方解耦,因此发送方不知道是谁对操作做出反应)的本质,您将无法轻松地进行操作发现这样的循环。我的意思是:准备好应对这种自我修改的迭代。它的“设计”。;-)

如何在检查碰撞时防止碰撞系统中断?

对于您与冲突检测有关的特定问题,可能足以使冲突事件异步,因此它们会排队等待,直到冲突管理器完成并在以后一批(或在框架中的某个较晚时间)执行。这是您的解决方案“传入队列”。

问题是:如果系统A将消息添加到系统B的队列中,则如果系统B的更新要晚于系统A(在同一游戏框架中),则它运行良好;否则,它将导致该消息处理下一个游戏框架(某些系统不希望使用)

简单:

while(!queue.empty()){queue.pop()。handle(); }

只需一遍又一遍地运行队列,直到没有消息残留。(如果现在尖叫“无限循环”,请记住,如果将其延迟到下一帧,则很可能会出现“邮件垃圾邮件”这个问题。您可以断言()进行相同数量的迭代以检测无限循环,如果你喜欢的话;))


请注意,我没有完全谈及“何时”处理异步消息。我认为,允许冲突检测模块在完成后刷新其消息非常好。您也可以将其视为“同步消息,延迟到循环结束”或“以一种可以在迭代时对其进行修改的方式实施迭代”的
巧妙方法

5

如果您实际上是在尝试利用ECS的面向数据设计的性质,那么您可能想考虑执行此操作的大多数DOD方法。

看一下BitSquid博客,特别是有关事件的部分。提出了一种与ECS良好配合的系统。将所有事件缓冲到一个漂亮的干净的按消息类型的队列中,就像ECS中按组件的系统一样。之后更新的系统可以针对特定的消息类型有效地遍历队列以对其进行处理。或者只是忽略它们。任何。

例如,CollisionSystem将生成一个充满碰撞事件的缓冲区。冲突后运行的任何其他系统都可以遍历列表并根据需要进行处理。

它保持了ECS设计的面向数据的并行性质,而没有消息注册之类的所有复杂性。只有真正关心特定事件类型的系统才会在该队列上进行迭代,并在消息队列上进行直接的单遍迭代才能获得尽可能高的效率。

如果您在每个系统中保持组件的顺序一致(例如,按实体ID或类似的顺序对所有组件进行排序),那么您甚至会得到好处,即将以最有效的顺序生成消息,以便对其进行迭代并在组件中查找相应的组件。处理系统。也就是说,如果您具有实体1、2和3,则将按该顺序生成消息,并且在处理消息时执行的组件查找将严格按照地址顺序递增(最快)。


1
+1,但我不敢相信这种方法没有缺点。这不是强迫我们对系统之间的相互依赖性进行硬编码吗?也许这些相互依赖性是要以某种方式进行硬编码的?
Patryk Czachurski

2
@Daedalus:如果游戏逻辑需要物理更新来执行正确的逻辑,那么您将如何具有这种依赖性?即使使用pubsub模型,也必须显式订阅仅由其他系统生成的某某消息类型。避免依赖关系很困难,而且通常只是找出正确的层。图形和物理是独立的,例如,但不会有更高的水平胶层,可确保内插的物理模拟的更新都反映在图形等
肖恩Middleditch

这应该是公认的答案。一种简单的解决方法是仅创建一种新类型的组件,例如CollisionResolvable,该组件将在发生碰撞后由感兴趣的系统进行处理。这很符合Drake的主张,但是每个细分循环都有一个系统。
user8363 2015年
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.