¿Qué es el diseño de motor de juego orientado a datos?

Es posible que haya oído hablar del diseño del motor de juego orientado a datos, un concepto relativamente nuevo que propone una mentalidad diferente al diseño orientado a objetos más tradicional. En este artículo, explicaré de qué se trata el DOD, y por qué algunos desarrolladores de motores de juego creen que podría ser el boleto para obtener un rendimiento espectacular.

Un poco de historia

En los primeros años de desarrollo de juegos, los juegos y sus motores estaban escritos en lenguajes de la vieja escuela, como C. Eran un producto de nicho, y la máxima prioridad era exprimir hasta el último ciclo de reloj del hardware lento. En la mayoría de los casos, solo había un número modesto de personas que pirateaban el código de un solo título, y sabían todo el código base de memoria. Las herramientas que estaban usando les habían servido bien, y C les estaba proporcionando los beneficios de rendimiento que les permitían sacar el máximo provecho de la CPU, y como estos juegos aún estaban vinculados por la CPU, aprovechando sus propios buffers de marcos, este fue un punto muy importante.

Con el advenimiento de las GPU que realizan el trabajo de procesamiento de números en los triángulos, texels, píxeles, etc., hemos llegado a depender menos de la CPU. Al mismo tiempo, la industria del juego ha experimentado un crecimiento constante: más y más personas quieren jugar cada vez más juegos, y esto a su vez ha llevado a que más y más equipos se unan para desarrollarlos.. 

La ley de Moore muestra que el crecimiento del hardware es exponencial, no lineal con respecto al tiempo: esto significa que cada par de años, la cantidad de transistores que podemos colocar en una sola placa no cambia en una cantidad constante: se duplica!

Los equipos más grandes necesitaban una mejor cooperación. En poco tiempo, los motores del juego, con su nivel complejo, inteligencia artificial, eliminación y lógica de procesamiento exigían que los programadores fueran más disciplinados, y su arma de elección era diseño orientado a objetos.

Como dijo una vez Paul Graham: 

En las grandes empresas, el software tiende a ser escrito por grandes (y cambiantes) equipos de programadores mediocres. La programación orientada a objetos impone una disciplina a estos programadores que evita que cualquiera de ellos haga demasiado daño.

Nos guste o no, esto debe ser cierto hasta cierto punto. Las compañías más grandes comenzaron a implementar juegos más grandes y mejores, y a medida que surgió la estandarización de las herramientas, los piratas informáticos que trabajan en juegos se convirtieron en partes que podrían ser intercambiadas con mayor facilidad. La virtud de un hacker en particular se volvió cada vez menos importante..

Problemas con el diseño orientado a objetos

Si bien el diseño orientado a objetos es un buen concepto que ayuda a los desarrolladores en grandes proyectos, como juegos, crear varias capas de abstracción y hacer que todos trabajen en su capa objetivo, sin tener que preocuparse por los detalles de implementación de los que están debajo, está obligado a danos algunos dolores de cabeza.

Vemos una explosión de programadores de programación paralelos que recogen todos los núcleos de procesadores disponibles para ofrecer velocidades de computación increíbles, pero al mismo tiempo, el escenario del juego se vuelve cada vez más complejo, y si queremos mantenernos al día con esa tendencia y seguir ofreciendo los marcos -por segundo que esperan nuestros jugadores, también tenemos que hacerlo. Al utilizar toda la velocidad que tenemos a mano, podemos abrir puertas para posibilidades completamente nuevas: usar el tiempo de CPU para reducir la cantidad de datos enviados a la GPU por completo, por ejemplo.

En la programación orientada a objetos, mantienes el estado dentro de un objeto, lo que requiere que introduzcas conceptos como primitivas de sincronización si deseas trabajar desde múltiples subprocesos. Tiene un nuevo nivel de direccionamiento indirecto para cada llamada de función virtual que realice. Y los patrones de acceso a la memoria generados por el código escrito de una manera orientada a objetos puede Sé horrible, de hecho, Mike Acton (Insomniac Games, ex Rockstar Games) tiene un gran conjunto de diapositivas que explican casualmente un ejemplo.. 

Del mismo modo, Robert Harper, profesor de la Universidad Carnegie Mellon, lo expresó de esta manera: 

La programación orientada a objetos es [...] tanto anti-modular como anti-paralela por su propia naturaleza, y por lo tanto no es adecuada para un currículo moderno de CS.

Hablar de OOP de esta manera es complicado, porque OOP abarca un amplio espectro de propiedades, y no todos están de acuerdo con lo que significa OOP. En este sentido, estoy hablando principalmente de OOP implementado por C ++, porque ese es el lenguaje que domina ampliamente el mundo de los motores de juegos..

Entonces, sabemos que los juegos deben ser paralelos porque siempre hay más trabajo que la CPU puede (pero no tiene que hacer) hacer, y los ciclos de gasto en espera de que la GPU termine el procesamiento es un desperdicio. También sabemos que los enfoques de diseño de OO comunes requieren que introduzcamos una contención de bloqueo costosa, y al mismo tiempo, puede violar la localidad del caché o causar una bifurcación innecesaria (¡lo que puede ser costoso!) En las circunstancias más inesperadas.

Si no aprovechamos los múltiples núcleos, seguimos usando la misma cantidad de recursos de CPU, incluso si el hardware mejora arbitrariamente (tiene más núcleos). Al mismo tiempo, podemos llevar a la GPU a sus límites porque es, por diseño, paralelo y capaz de realizar cualquier cantidad de trabajo simultáneamente. Esto puede interferir con nuestra misión de proporcionar a los jugadores la mejor experiencia en su hardware, ya que claramente no lo estamos utilizando al máximo..

Esto plantea la pregunta: ¿deberíamos repensar nuestros paradigmas por completo??

Introduzca: Diseño orientado a datos

Algunos defensores de esta metodología han llamado Es un diseño orientado a datos, pero la verdad es que el concepto general se conoce desde hace mucho tiempo. Su premisa básica es simple: construya su código alrededor de las estructuras de datos y describa lo que quiere lograr en términos de manipulaciones de estas estructuras

Hemos escuchado este tipo de charla antes: Linus Torvalds, el creador de Linux y Git, dijo en una publicación de la lista de correo de Git que es un gran defensor de "diseñar el código alrededor de los datos, y no al revés", y acredita esto como una de las razones del éxito de Git. Él incluso afirma que la diferencia entre un buen programador y uno malo es si a ella le preocupan las estructuras de datos o el código en sí..

La tarea puede parecer contraria a la intuición al principio, porque requiere que inviertas tu modelo mental al revés. Pero piénselo de esta manera: un juego, mientras se ejecuta, captura todas las entradas del usuario y todas las piezas de alto rendimiento (aquellas en las que tendría sentido abandonar el estándar todo es un objeto Filosofía) no se basan en factores externos, como la red o IPC. Por lo que usted sabe, un juego consume eventos de usuario (se mueve el mouse, se presiona el botón de la palanca de mando, etc.) y el estado actual del juego, y los convierte en un nuevo conjunto de datos, por ejemplo, lotes que se envían a la GPU. Las muestras de PCM que se envían a la tarjeta de audio y un nuevo estado de juego.

Este "intercambio de datos" se puede dividir en muchos más subprocesos. Un sistema de animación toma los siguientes datos de fotogramas clave y el estado actual y produce un nuevo estado. Un sistema de partículas toma su estado actual (posiciones de partículas, velocidades, etc.) y un avance en el tiempo y produce un nuevo estado. Un algoritmo de selección toma un conjunto de rendiciones candidatas y produce un conjunto más pequeño de rendiciones. Casi todo en un motor de juego puede ser pensado como manipular una parte de datos para producir otra parte de datos.

Los procesadores aman la localidad de referencia y la utilización del caché. Por lo tanto, en el diseño orientado a datos, tendemos a, siempre que sea posible, organizar todo en matrices grandes y homogéneas y, siempre que sea posible, ejecutar algoritmos de fuerza bruta coherentes con la memoria caché en lugar de uno potencialmente más sofisticado (que tiene un mejor costo de Big O, pero no abarca las limitaciones de arquitectura del hardware en el que funciona). 

Cuando se realiza por fotograma (o varias veces por fotograma), esto potencialmente ofrece enormes recompensas de rendimiento. Por ejemplo, las personas en Scalyr informan que buscan archivos de registro a 20GB / seg usando un escaneo lineal de fuerza bruta cuidadosamente elaborado pero ingenuo.. 

Cuando procesamos objetos, debemos pensar en ellos como "cajas negras" y llamar a sus métodos, que a su vez acceden a los datos y nos dan lo que queremos (o hacemos los cambios que queremos). Esto es excelente para trabajar por la facilidad de mantenimiento, pero no saber cómo se distribuyen nuestros datos puede ser perjudicial para el rendimiento.

Ejemplos

El diseño orientado a datos nos hace pensar en los datos, así que hagamos algo también diferente a lo que solemos hacer. Considera este pedazo de código:

void MyEngine :: queueRenderables () for (auto it = mRenderables.begin (); it! = mRenderables.end (); ++ it) if ((* it) -> isVisible ()) queueRenderable (* it ); 

Aunque se ha simplificado mucho, este patrón común es lo que se ve a menudo en los motores de juegos orientados a objetos. Pero espere: si muchos rendibles no son realmente visibles, nos encontramos con muchas predicciones erróneas de las sucursales que hacen que el procesador destruya algunas instrucciones que había ejecutado con la esperanza de que se tomara una rama en particular.. 

Para escenas pequeñas, esto obviamente no es un problema. Pero, ¿cuántas veces hace esto en particular, no solo cuando se ponen en cola los rendidores, sino al iterar a través de luces de escena, divisiones de mapas de sombras, zonas, etc.? ¿Qué hay de AI o actualizaciones de animación? Multiplique todo lo que hace a lo largo de la escena, vea cuántos ciclos de reloj expulsa, calcule cuánto tiempo tiene su procesador disponible para entregar todos los lotes de GPU para un ritmo constante de 120 FPS, y verá que estas cosas puede escala a una cantidad considerable. 

Sería gracioso si, por ejemplo, un pirata informático que trabajara en una aplicación web considerara micro-optimizaciones tan minúsculas, pero sabemos que los juegos son sistemas en tiempo real donde las restricciones de recursos son increíblemente limitadas, por lo que esta consideración no es errónea para nosotros..

Para evitar que esto suceda, pensémoslo de otra manera: ¿qué sucede si mantenemos la lista de rendimientos visibles en el motor? Claro, sacrificaríamos la sintaxis clara de miRenerable-> ocultar () y violar algunos principios OOP, pero luego podríamos hacer esto:

void MyEngine :: queueRenderables () for (auto it = mVisibleRenderables.begin (); it! = mVisibleRenderables.end (); ++ it) queueRenderable (* it); 

¡Hurra! No hay predicciones erróneas de rama, y ​​suponiendo mVisibleRenderables es agradable std :: vector (que es una matriz contigua), podríamos haber reescrito esto como un rápido memcpy llamada (con algunas actualizaciones adicionales a nuestras estructuras de datos, probablemente).

Ahora, puede llamarme por el puro sabor de estas muestras de código y tendrá toda la razón: esto está simplificado mucho. Pero para ser honesto, ni siquiera he arañado la superficie. Pensar en las estructuras de datos y sus relaciones nos abre un montón de posibilidades que no hemos pensado antes. Veamos algunos de ellos a continuación..

Paralelización y Vectorización

Si tenemos funciones simples y bien definidas que operan en grandes bloques de datos como bloques de construcción básicos para nuestro procesamiento, es fácil generar cuatro, ocho u 16 hilos de trabajo y darles a cada uno una parte de los datos para mantener toda la CPU. núcleos ocupados Sin exclusión mutua, contención atómica o de bloqueo, y una vez que necesite los datos, solo necesita unirse en todos los subprocesos y esperar a que finalicen. Si necesita ordenar los datos en paralelo (una tarea muy frecuente al preparar el material para enviarlo a la GPU), debe pensar en esto desde una perspectiva diferente: estas diapositivas pueden ayudar.

Como beneficio adicional, dentro de un hilo, puede usar las instrucciones vectoriales SIMD (como SSE / SSE2 / SSE3) para lograr un aumento de velocidad adicional. A veces, puede lograr esto solo colocando sus datos de una manera diferente, como colocando matrices vectoriales en una estructura de matrices (SoA) de manera (como XXX ... YYY ... ZZZ ... ) en lugar de la matriz de estructuras convencional (AoS; eso sería XYZXYZXYZ ... ). Apenas estoy rascando la superficie aquí; Puedes encontrar más información en el Otras lecturas sección a continuación.

Cuando nuestros algoritmos tratan con los datos directamente, resulta trivial paralelizarlos, y también podemos evitar algunos inconvenientes de velocidad..

Pruebas unitarias que no sabías que era posible

Tener funciones simples sin efectos externos hace que sean fáciles de probar por unidad. Esto puede ser especialmente bueno en una forma de prueba de regresión para algoritmos que le gustaría intercambiar dentro y fuera fácilmente. 

Por ejemplo, puede crear un conjunto de pruebas para el comportamiento de un algoritmo de sacrificio, configurar un entorno orquestado y medir exactamente cómo se realiza. Cuando diseña un nuevo algoritmo de eliminación, ejecuta la misma prueba nuevamente sin cambios. Mide el rendimiento y la corrección, para que pueda tener la evaluación a su alcance. 

A medida que adquiera más en los enfoques de diseño orientados a datos, encontrará cada vez más fácil probar aspectos de su motor de juego..

Combinando clases y objetos con datos monolíticos

El diseño orientado a datos no se opone en absoluto a la programación orientada a objetos, solo algunas de sus ideas. Como resultado, usted puede usar perfectamente ideas desde el diseño orientado a datos y aún así obtener la mayoría de las abstracciones y modelos mentales a los que está acostumbrado. 

Eche un vistazo, por ejemplo, al trabajo en OGRE versión 2.0: Matias Goldberg, el cerebro detrás de este esfuerzo, eligió almacenar datos en arreglos grandes y homogéneos, y tiene funciones que se repiten en arreglos completos en lugar de trabajar en un solo dato. , para acelerar el ogro. Según un punto de referencia (que él admite es muy injusto, pero la ventaja de rendimiento medida no puede ser solamente por eso) funciona ahora tres veces más rápido. No solo eso, sino que conservó muchas de las abstracciones de clase antiguas y familiares, por lo que la API estaba lejos de ser una reescritura completa..

Es practico?

Hay mucha evidencia de que los motores de juego de esta manera pueden y serán desarrollados.

El blog de desarrollo de Molecule Engine tiene una serie llamada Aventuras en el diseño orientado a datos,y contiene muchos consejos útiles sobre dónde se implementó el DOD con excelentes resultados.

DICE parece estar interesado en el diseño orientado a los datos, ya que lo han empleado en el sistema de eliminación de Frostbite Engine (¡y también han conseguido importantes aceleraciones!). Algunas otras diapositivas de ellos también incluyen el uso de diseño orientado a datos en el subsistema AI que vale la pena mirar, también.

Además de eso, los desarrolladores como el mencionado Mike Acton parecen estar abrazando el concepto. Hay algunos puntos de referencia que demuestran que gana mucho en rendimiento, pero no he visto mucha actividad en el frente del diseño orientado a datos desde hace bastante tiempo. Por supuesto, podría ser solo una moda, pero sus premisas principales parecen muy lógicas. Seguro que hay mucha inercia en este negocio (y en cualquier otro negocio de desarrollo de software), por lo que esto puede dificultar la adopción a gran escala de dicha filosofía. O tal vez no es una gran idea como parece ser. ¿Qué piensas? Los comentarios son muy bienvenidos.!

Otras lecturas

  1. Diseño orientado a datos (o por qué podría estar disparándose en el pie con OOP)
  2. Introducción al diseño orientado a datos [DICE] 
  3. Una discusión bastante agradable sobre el desbordamiento de pila 
  4. Un libro en línea de Richard Fabian que explica muchos de los conceptos. 
  5. Un punto de referencia que muestra el otro lado de la historia, un resultado aparentemente contrario a la intuición. 
  6. La revisión de Mike Acton de OgreNode.cpp, que revela algunas dificultades comunes del desarrollo del motor de juegos OOP