¿Cómo implementar adecuadamente el manejo de mensajes en un sistema de entidad basado en componentes?


30

Estoy implementando una variante de sistema de entidad que tiene:

  • Una clase de entidad que es poco más que una ID que une componentes

  • Un grupo de clases de componentes que no tienen "lógica de componentes", solo datos

  • Un grupo de clases de sistema (también conocido como "subsistemas", "administradores"). Estos hacen todo el procesamiento lógico de la entidad. En la mayoría de los casos básicos, los sistemas simplemente iteran a través de una lista de entidades que les interesan y realizan una acción sobre cada una de ellas.

  • Un objeto de clase MessageChannel que es compartido por todos los sistemas de juego. Cada sistema puede suscribirse a un tipo específico de mensajes para escuchar y también puede usar el canal para transmitir mensajes a otros sistemas

La variante inicial del manejo de mensajes del sistema era algo como esto:

  1. Ejecute una actualización en cada sistema de juego secuencialmente
  2. Si un sistema hace algo a un componente y esa acción podría ser de interés para otros sistemas, el sistema envía un mensaje apropiado (por ejemplo, un sistema llama

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

    cada vez que se mueve una entidad)

  3. Cada sistema que se suscribió al mensaje específico obtiene su método de manejo de mensajes llamado

  4. Si un sistema maneja un evento y la lógica de procesamiento de eventos requiere que se transmita otro mensaje, el mensaje se transmite de inmediato y se llama a otra cadena de métodos de procesamiento de mensajes

Esta variante estuvo bien hasta que comencé a optimizar el sistema de detección de colisión (se estaba volviendo muy lento a medida que aumentaba el número de entidades). Al principio, solo iteraría cada par de entidades utilizando un algoritmo de fuerza bruta simple. Luego agregué un "índice espacial" que tiene una cuadrícula de celdas que almacena entidades que están dentro del área de una celda específica, lo que permite hacer verificaciones solo en entidades en celdas vecinas.

Cada vez que una entidad se mueve, el sistema de colisión verifica si la entidad está colisionando con algo en la nueva posición. Si es así, se detecta una colisión. Y si ambas entidades en colisión son "objetos físicos" (ambas tienen un componente RigidBody y están destinadas a alejarse entre sí para no ocupar el mismo espacio), un sistema de separación de cuerpo rígido dedicado le pide al sistema de movimiento que mueva las entidades a algunos posiciones específicas que los separarían. Esto a su vez hace que el sistema de movimiento envíe mensajes notificando sobre las posiciones cambiadas de la entidad. El sistema de detección de colisión está destinado a reaccionar porque necesita actualizar su índice espacial.

En algunos casos, causa un problema porque el contenido de la celda (una lista genérica de objetos de entidad en C #) se modifica mientras se repite, lo que provoca que el iterador arroje una excepción.

Entonces ... ¿cómo puedo evitar que el sistema de colisión se interrumpa mientras verifica si hay colisiones?

Por supuesto, podría agregar una lógica "inteligente" / "complicada" que garantice que el contenido de la celda se repita correctamente, pero creo que el problema no radica en el sistema de colisión en sí (también tuve problemas similares en otros sistemas), sino en la forma los mensajes se manejan a medida que viajan de un sistema a otro. Lo que necesito es alguna forma de asegurarme de que un método de manejo de eventos específico haga su trabajo sin interrupciones.

Lo que he intentado:

  • Colas de mensajes entrantes . Cada vez que un sistema transmite un mensaje, el mensaje se agrega a las colas de mensajes de los sistemas que están interesados ​​en él. Estos mensajes se procesan cuando una actualización del sistema se llama cada trama. El problema : si un sistema A agrega un mensaje a la cola B del sistema, funciona bien si el sistema B debe actualizarse más tarde que el sistema A (en el mismo marco del juego); de lo contrario, hace que el mensaje procese el siguiente marco del juego (no es deseable para algunos sistemas)
  • Colas de mensajes salientes . Mientras un sistema maneja un evento, cualquier mensaje que transmita se agrega a la cola de mensajes salientes. Los mensajes no necesitan esperar a que se procese una actualización del sistema: se manejan "de inmediato" una vez que el manejador de mensajes inicial ha finalizado su trabajo. Si el manejo de los mensajes hace que se transmitan otros mensajes, también se agregan a una cola saliente, por lo que todos los mensajes se manejan en el mismo marco. El problema: si el sistema de vida útil de la entidad (implementé la gestión de vida útil de la entidad con un sistema) crea una entidad, notifica algunos sistemas A y B al respecto. Mientras el sistema A procesa el mensaje, provoca una cadena de mensajes que eventualmente hace que la entidad creada sea destruida (por ejemplo, se creó una entidad de bala justo donde choca con algún obstáculo, lo que hace que la bala se autodestruya). Mientras se resuelve la cadena de mensajes, el sistema B no recibe el mensaje de creación de la entidad. Entonces, si el sistema B también está interesado en el mensaje de destrucción de la entidad, lo obtiene, y solo después de que la "cadena" termina de resolverse, obtiene el mensaje inicial de creación de la entidad. Esto hace que el mensaje de destrucción sea ignorado, el mensaje de creación sea "aceptado",

EDITAR - RESPUESTAS A PREGUNTAS, COMENTARIOS:

  • ¿Quién modifica el contenido de la celda mientras el sistema de colisión itera sobre ellos?

Mientras el sistema de colisión está realizando comprobaciones de colisión en alguna entidad y sus vecinos, puede detectarse una colisión y el sistema de la entidad enviará un mensaje que será reaccionado de inmediato por otros sistemas. La reacción al mensaje puede hacer que se creen otros mensajes y también se manejen de inmediato. Por lo tanto, algún otro sistema podría crear un mensaje de que el sistema de colisión tendría que procesarse de inmediato (por ejemplo, una entidad se movió por lo que el sistema de colisión necesita actualizar su índice espacial), a pesar de que las verificaciones de colisión anteriores aún no se habían terminado.

  • ¿No puedes trabajar con una cola de mensajes salientes global?

Intenté una sola cola global recientemente. Causa nuevos problemas. Problema: muevo una entidad de tanque a una entidad de pared (el tanque se controla con el teclado). Entonces decido cambiar la dirección del tanque. Para separar el tanque y la pared de cada marco, el sistema CollidingRigidBodySeparationSystem aleja el tanque de la pared en la menor cantidad posible. La dirección de separación debe ser opuesta a la dirección de movimiento del tanque (cuando comienza el sorteo del juego, el tanque debe verse como si nunca se hubiera movido hacia la pared). Pero la dirección se vuelve opuesta a la NUEVA dirección, moviendo el tanque a un lado diferente de la pared de lo que era inicialmente. Por qué ocurre el problema: así es como se manejan los mensajes ahora (código simplificado):

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);
        }
    }
}

El código fluye así (supongamos que no es el primer fotograma del juego):

  1. Los sistemas comienzan a procesar TimePassedMessage
  2. InputHandingSystem convierte las pulsaciones de teclas en acción de entidad (en este caso, una flecha izquierda se convierte en acción MoveWest). La acción de la entidad se almacena en el componente ActionExecutor
  3. ActionExecutionSystem , en reacción a la acción de la entidad, agrega un MotionDirectionChangeRequestedMessage al final de la cola de mensajes
  4. MovementSystem mueve la posición de la entidad en función de los datos del componente Velocity y agrega el mensaje PositionChangedMessage al final de la cola. El movimiento se realiza utilizando la dirección / velocidad del movimiento del cuadro anterior (digamos norte)
  5. Los sistemas dejan de procesar TimePassedMessage
  6. Los sistemas comienzan a procesar MotionDirectionChangeRequestedMessage
  7. Movimiento El sistema cambia la velocidad de la entidad / dirección del movimiento según lo solicitado
  8. Los sistemas dejan de procesar MotionDirectionChangeRequestedMessage
  9. Los sistemas comienzan a procesar PositionChangedMessage
  10. CollisionDetectionSystem detecta que debido a que una entidad se movió, se topó con otra entidad (el tanque entró dentro de una pared). Agrega un mensaje CollisionOccuredMessage a la cola
  11. Los sistemas dejan de procesar PositionChangedMessage
  12. Los sistemas comienzan a procesar CollisionOccuredMessage
  13. CollidingRigidBodySeparationSystem reacciona a la colisión separando el tanque y la pared. Como la pared es estática, solo se mueve el tanque. La dirección de movimiento de los tanques se usa como un indicador de dónde vino el tanque. Está desplazado en una dirección opuesta

ERROR: Cuando el tanque movió este cuadro, se movió usando la dirección de movimiento del cuadro anterior, pero cuando se estaba separando, se usó la dirección de movimiento de ESTE cuadro, aunque ya era diferente. ¡No es así como debería funcionar!

Para evitar este error, la antigua dirección de movimiento debe guardarse en algún lugar. Podría agregarlo a algún componente solo para corregir este error específico, pero ¿este caso no indica alguna forma fundamentalmente incorrecta de manejar los mensajes? ¿Por qué debería importarle al sistema de separación qué dirección de movimiento utiliza? ¿Cómo puedo resolver este problema con elegancia?

  • Es posible que desee leer gamadu.com/artemis para ver qué hicieron con Aspects, qué lado explica algunos de los problemas que está viendo.

En realidad, he estado familiarizado con Artemis desde hace bastante tiempo. Investigué su código fuente, leí los foros, etc. Pero he visto que se mencionan "Aspectos" solo en unos pocos lugares y, por lo que entiendo, básicamente significan "Sistemas". Pero no puedo ver cómo Artemis evita algunos de mis problemas. Ni siquiera usa mensajes.

  • Consulte también: "Comunicación de la entidad: Cola de mensajes vs Publicar / Suscribir vs Señal / Ranuras"

Ya leí todas las preguntas de gamedev.stackexchange sobre los sistemas de entidades. Este no parece discutir los problemas que estoy enfrentando. ¿Me estoy perdiendo de algo?

  • Maneje los dos casos de manera diferente, la actualización de la cuadrícula no necesita depender de los mensajes de movimiento, ya que es parte del sistema de colisión

No estoy seguro de lo que quieres decir. Las implementaciones anteriores de CollisionDetectionSystem solo verificaban las colisiones en una actualización (cuando se manejaba un TimePassedMessage), pero tenía que minimizar las verificaciones tanto como podía debido al rendimiento. Así que cambié a la verificación de colisiones cuando una entidad se mueve (la mayoría de las entidades en mi juego son estáticas).


Hay algo que no me queda claro. ¿Quién modifica los contenidos de la celda mientras el sistema de colisión itera sobre ellos?
Paul Manta

¿No puedes trabajar con una cola de mensajes salientes global? Entonces, todos los mensajes allí se envían cada vez que se hace un sistema, esto incluye la autodestrucción del sistema.
Roy T.

Si desea mantener este diseño complicado, debe seguir a @RoyT. Como consejo, es la única forma (sin mensajes complejos basados ​​en el tiempo) para manejar su problema de secuencia. Es posible que desee leer gamadu.com/artemis para ver qué hicieron con Aspects, qué lado explica algunos de los problemas que está viendo.
Patrick Hughes


2
Es posible que desee aprender cómo lo hizo Axum descargando el CTP y compilando algo de código, y luego invirtiendo el resultado en C # mediante ILSpy. La transmisión de mensajes es una característica importante de los lenguajes de modelos de actores y estoy seguro de que Microsoft sabe lo que están haciendo, por lo que es posible que tengan la "mejor" implementación.
Jonathan Dickinson

Respuestas:


12

Probablemente hayas oído hablar del antipatrón de objetos God / Blob. Bueno, tu problema es un bucle God / Blob. Jugar con su sistema de transmisión de mensajes en el mejor de los casos proporcionará una solución de curita y, en el peor de los casos, será una completa pérdida de tiempo. De hecho, tu problema no tiene nada que ver específicamente con el desarrollo del juego. Me sorprendí tratando de modificar una colección mientras la repetía varias veces, y la solución es siempre la misma: subdividir, subdividir, subdividir.

Según entiendo la redacción de su pregunta, su método de actualización de su sistema de colisión actualmente se parece en general al siguiente.

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

Escrito claramente así, puedes ver que tu ciclo tiene tres responsabilidades, cuando solo debería tener una. Para resolver su problema, divida su bucle actual en tres bucles separados que representan tres pases algorítmicos diferentes .

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

Al subdividir su ciclo original en tres subloops, ya no intenta modificar la colección sobre la que está iterando actualmente. Tenga en cuenta también que no está haciendo más trabajo que en su ciclo original y, de hecho, puede obtener algunas ganancias de caché al hacer las mismas operaciones muchas veces secuencialmente.

También hay un beneficio adicional, que es que ahora puede introducir paralelismo en su código. Su enfoque de bucle combinado es inherentemente serial (¡que es fundamentalmente lo que le dice la excepción de modificación concurrente!), Porque cada iteración de bucle potencialmente lee y escribe en su mundo de colisión. Sin embargo, los tres subloops que presento arriba, todos leen o escriben, pero no ambos. Como mínimo, la primera pasada, comprobar todas las posibles colisiones, se ha vuelto vergonzosamente paralela, y dependiendo de cómo escriba su código, la segunda y tercera pasadas también podrían serlo.


Estoy totalmente de acuerdo con esto. Estoy usando este enfoque muy similar en mi juego y creo que esto valdrá la pena a largo plazo. Así es como debería funcionar el sistema de colisión (o el administrador) (realmente creo que es posible no tener un sistema de mensajería).
Emiliano

11

¿Cómo implementar adecuadamente el manejo de mensajes en un sistema de entidad basado en componentes?

Diría que desea dos tipos de mensajes: Sincrónico y Asíncrono. Los mensajes síncronos se manejan inmediatamente mientras que los asíncronos no se manejan en el mismo marco de pila (pero se pueden manejar en el mismo marco de juego). La decisión que se toma generalmente en una base "por clase de mensaje", por ejemplo, "todos los mensajes EnemyDied son asíncronos".

Algunos eventos se manejan mucho más fácilmente con una de estas formas. Por ejemplo, en mi experiencia, un evento ObjectGetsDeletedNow es mucho menos atractivo y las devoluciones de llamada son mucho más difíciles de implementar que ObjectWillBeDeletedAtEndOfFrame. Por otra parte, cualquier manejador de mensajes tipo "veto" (código que puede cancelar o modificar ciertas acciones mientras se ejecutan, como un efecto de Escudo modifica el DamageEvent ) no será fácil en entornos asincrónicos, pero es pan comido llamadas sincrónicas

Asíncrono puede ser más eficiente en algunos casos (por ejemplo, puede omitir algunos controladores de eventos cuando el objeto se elimina más tarde de todos modos). A veces, la sincronización es más eficiente, especialmente cuando calcular el parámetro para un evento es costoso y prefiere pasar las funciones de devolución de llamada para recuperar ciertos parámetros en lugar de valores ya calculados (en caso de que a nadie le interese este parámetro en particular).

Ya mencionó otro problema general con los sistemas de mensajes síncronos solamente: según mi experiencia con los sistemas de mensajes sincrónicos, uno de los casos más frecuentes de errores y duelo en general es el cambio de listas al iterar sobre estas listas.

Piénselo: tiene la naturaleza de sincronizado (maneja inmediatamente todos los efectos secundarios de alguna acción) y el sistema de mensajes (desacopla el receptor del remitente para que el remitente no sepa quién está reaccionando a las acciones) que no podrá fácilmente detectar tales bucles. Lo que digo es: prepárate para manejar mucho este tipo de iteración auto modificable. Su tipo de "por diseño". ;-)

¿Cómo puedo evitar que el sistema de colisión se interrumpa mientras verifica las colisiones?

Para su problema particular con la detección de colisión, podría ser lo suficientemente bueno para hacer que los eventos de colisión sean asíncronos, de modo que se pongan en cola hasta que el administrador de colisiones termine y se ejecute como un lote después (o en algún momento posterior en el marco). Esta es su solución "cola entrante".

El problema: si un sistema A agrega un mensaje a la cola B del sistema, funciona bien si el sistema B debe actualizarse más tarde que el sistema A (en el mismo marco del juego); de lo contrario, hace que el mensaje procese el siguiente marco del juego (no es deseable para algunos sistemas)

Fácil:

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

Simplemente ejecute la cola una y otra vez hasta que no quede ningún mensaje. (Si grita "bucle sin fin" ahora, recuerde que lo más probable es que tenga este problema como "envío de mensajes no deseados" si se retrasa al siguiente cuadro. Puede afirmar () durante un número razonable de iteraciones para detectar bucles sin fin, si te apetece;))


Tenga en cuenta que no hablé exactamente sobre "cuándo" se manejan los mensajes asincrónicos. En mi opinión, está perfectamente bien permitir que el módulo de detección de colisiones vacíe sus mensajes después de que finalice. También podría pensar en esto como "mensajes síncronos, retrasados ​​hasta el final del ciclo" o alguna forma ingeniosa de "simplemente implementar la iteración de una manera que pueda modificarse mientras se repite"
Imi

5

Si realmente está tratando de hacer uso de la naturaleza de diseño orientado a datos de ECS, entonces es posible que desee pensar en la forma más DOD de hacerlo.

Eche un vistazo al blog de BitSquid , específicamente la parte sobre eventos. Se presenta un sistema que combina bien con ECS. Guarda todos los eventos en una cola limpia por tipo de mensaje, de la misma manera que los sistemas en un ECS son por componente. Los sistemas actualizados después pueden iterar eficientemente sobre la cola para un tipo de mensaje particular para procesarlos. O simplemente ignóralos. Cualquier.

Por ejemplo, el sistema de colisión generaría un búfer lleno de eventos de colisión. Cualquier otro sistema que se ejecute después de una colisión puede recorrer la lista y procesarlos según sea necesario.

Mantiene la naturaleza paralela orientada a datos del diseño de ECS sin toda la complejidad del registro de mensajes o similar. Solo los sistemas que realmente se preocupan por un tipo particular de evento iteran sobre la cola para ese tipo, y hacer una iteración directa de un solo paso sobre la cola de mensajes es lo más eficiente posible.

Si mantiene los componentes ordenados consistentemente en cada sistema (por ejemplo, ordene todos los componentes por ID de entidad o algo así), incluso obtendrá el beneficio de que los mensajes se generarán en el orden más eficiente para iterar sobre ellos y buscar los componentes correspondientes en el sistema de procesamiento. Es decir, si tiene entidades 1, 2 y 3, los mensajes se generan en ese orden y las búsquedas de componentes realizadas mientras se procesa el mensaje estarán en un orden estrictamente creciente de direcciones (que es el más rápido).


1
+1, pero no puedo creer que este enfoque no tenga desventajas. ¿No nos obliga esto a codificar las interdependencias entre sistemas? ¿O tal vez estas interdependencias están destinadas a ser codificadas, de una forma u otra?
Patryk Czachurski

2
@Daedalus: si la lógica del juego necesita actualizaciones físicas para hacer la lógica correcta, ¿cómo no vas a tener esa dependencia? Incluso con un modelo de pubsub, debe suscribirse explícitamente a tal tipo de mensaje que solo es generado por algún otro sistema. Evitar dependencias es difícil, y se trata principalmente de descubrir las capas correctas. Los gráficos y la física son independientes, por ejemplo, pero habrá una capa de pegamento de mayor nivel que garantiza que las actualizaciones de la simulación de física interpolada se reflejen en los gráficos, etc.
Sean Middleditch

Esta debería ser la respuesta aceptada. Una forma sencilla de hacerlo es crear un nuevo tipo de componente, por ejemplo, CollisionResolvable, que será procesado por todos los sistemas interesados ​​en hacer las cosas después de una colisión. Lo que encajaría bien con la propuesta de Drake, sin embargo, hay un sistema para cada bucle de subdivisión.
user8363
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.