Diseño orientado a datos (o por qué, usando OOP, probablemente te dispares en el pie)

imagen

Imagina esta imagen: se acerca el final del ciclo de desarrollo, tu juego apenas se arrastra, pero en el generador de perfiles no puedes encontrar áreas problemáticas obvias. ¿Quién tiene la culpa? Patrones de memoria de acceso aleatorio y errores persistentes de caché. Intentando mejorar el rendimiento, intenta paralelizar partes del código, pero vale la pena los esfuerzos heroicos, y al final, debido a toda la sincronización que tuvo que agregarse, la aceleración apenas se nota. Además, el código es tan complicado que corregir errores causa aún más problemas, y la idea de agregar nuevas características se descarta de inmediato. ¿Te suena familiar?

Tal desarrollo de eventos describe con precisión casi todos los juegos en los que participé durante los últimos diez años. Las razones no están en los lenguajes de programación o las herramientas de desarrollo, o incluso la falta de disciplina. En mi experiencia, en gran medida, se debe culpar a la programación orientada a objetos (OOP) y su cultura circundante. ¡OOP puede no ayudar, pero interferir con sus proyectos!

Se trata de datos


OOP ha penetrado tanto en la cultura existente del desarrollo de videojuegos que cuando piensas en un juego, es difícil imaginar algo más que objetos. Durante muchos años, hemos estado creando clases para automóviles, jugadores y máquinas estatales. Cuales son las alternativas? Programación procesal? Lenguajes funcionales? Lenguajes de programación exóticos?

El diseño orientado a datos es otra forma de diseñar software diseñado para resolver todos estos problemas. El elemento principal de la programación de procedimientos son las llamadas a procedimientos, y OOP trata principalmente con objetos. Tenga en cuenta que en ambos casos el código se coloca en el centro: en un caso, se trata de procedimientos (o funciones) ordinarios, en el otro, código agrupado asociado con un determinado estado interno. El diseño orientado a datos cambia el foco de atención de los objetos a los datos en sí: el tipo de datos, su ubicación en la memoria, los métodos para leerlos y procesarlos en el juego.

La programación por definición es una forma de convertir datos: el acto de crear una secuencia de instrucciones de máquina que describen el proceso de procesamiento de datos de entrada y creación de datos de salida. Un juego no es más que un programa interactivo, así que ¿no sería más lógico concentrarse principalmente en los datos y no en el código que los procesa?

Para no confundirlo, explicaré de inmediato: el diseño orientado a datos no significa que el programa esté basado en datos. Un juego basado en datos suele ser un juego cuya funcionalidad está en gran medida fuera del código; Permite que los datos determinen el comportamiento del juego. Este concepto es independiente del diseño orientado a datos y puede usarse en cualquier método de programación.

Datos perfectos


Secuencia de llamadas con un enfoque orientado a objetos

Figura 1a. Secuencia de llamada con un enfoque orientado a objetos

Si miramos el programa en términos de datos, ¿cómo serán los datos ideales? Depende de los datos en sí y de cómo usarlos. En general, los datos ideales están en un formato que puede usarse con un mínimo esfuerzo. En el mejor de los casos, el formato coincidirá completamente con el resultado de salida esperado, es decir, el procesamiento consiste solo en copiar los datos. Muy a menudo, un esquema de datos ideal se ve como grandes bloques de datos homogéneos adyacentes que pueden procesarse secuencialmente. Sea como fuere, el objetivo es minimizar el número de transformaciones; si es posible, "hornee" los datos en este formato ideal de antemano, en la etapa de creación de recursos del juego.

Dado que el diseño orientado a datos pone los datos primero, podemos crear la arquitectura de un programa completo en torno a un formato de datos ideal. No siempre lograremos que sea completamente perfecto (así como el código rara vez se parece a OOP de un libro de texto), pero este es nuestro objetivo principal, que siempre recordamos. Cuando lo logramos, la mayoría de los problemas mencionados al principio del artículo simplemente se disuelven (más en esta próxima sección).

Cuando pensamos en objetos, recordamos inmediatamente los árboles: árboles de herencia, árboles de anidación o árboles de mensajes, y nuestros datos se ordenan naturalmente de esta manera. Por lo tanto, cuando realizamos una operación en un objeto, esto generalmente lleva al hecho de que el objeto, a su vez, accede a otros objetos en el árbol. Al iterar sobre varios objetos, realizar la misma operación generará operaciones posteriores, completamente diferentes para cada objeto (ver Figura 1a).

Secuencia de llamadas con un enfoque orientado a datos

Figura 1b. Secuencia de llamadas en una técnica orientada a datos

Para obtener el mejor esquema de almacenamiento de datos, puede ser útil dividir cada objeto en diferentes componentes y agrupar componentes del mismo tipo en la memoria, independientemente del objeto del que los tomamos. Tal orden conduce a la creación de grandes bloques de datos homogéneos, lo que nos permite procesar los datos secuencialmente (ver Figura 1b). La razón principal del poder del concepto de diseño orientado a datos es que funciona muy bien con grandes grupos de objetos. OOP, por definición, funciona con un solo objeto. Recuerda el último juego en el que trabajaste: ¿con qué frecuencia en el código había lugares donde tenías que trabajar con un solo elemento? Un enemigo? Un vehículo? Una forma de encontrar nodo? Una bala? Una pieza? Nunca! Donde hay uno, hay varios más. OOP ignora esto y trabaja con cada objeto individualmente. Por lo tanto, podemos simplificar el trabajo para nosotros y para el equipo organizando los datos de manera que sea necesario procesar muchos elementos del mismo tipo.

¿Te parece extraño este enfoque? ¿Pero sabes que? Lo más probable es que ya lo esté utilizando en algunas partes del código: a saber, en el sistema de partículas. El diseño orientado a datos convierte toda la base de código en un gran sistema de partículas. Es posible que este método parezca más familiar para los desarrolladores de juegos; debería llamarse programación basada en partículas.

Beneficios del diseño orientado a datos


Si en primer lugar pensamos en los datos y creamos la arquitectura del programa sobre la base de esto, esto nos dará muchas ventajas.

Paralelismo


Hoy en día es imposible deshacerse del hecho de que necesitamos trabajar con múltiples núcleos. Aquellos que intentaron paralelizar el código OOP pueden confirmar cuán compleja, propensa a errores y quizás no particularmente eficiente es la tarea. A menudo, debe agregar muchas primitivas de sincronización para evitar el acceso simultáneo a los datos de varios subprocesos, y generalmente muchos subprocesos están inactivos durante mucho tiempo, esperando que otros subprocesos terminen de funcionar. Como resultado, las ganancias de productividad son bastante mediocres.

Si aplicamos un diseño orientado a datos, la paralelización se vuelve mucho más simple: tenemos datos de entrada, una pequeña función que los procesa y genera datos. Algo similar se puede dividir fácilmente en varias secuencias con una sincronización mínima entre ellas. Incluso puede dar un paso más y ejecutar este código en procesadores con memoria local (por ejemplo, en SPU de procesadores Cell) sin cambiar ninguna operación.

Uso de caché


Además de usar múltiples núcleos, una de las formas clave de lograr un alto rendimiento en equipos modernos con tuberías de instrucción profundas y sistemas de memoria lenta con varios niveles de caché es la implementación de acceso a datos que es conveniente para el almacenamiento en caché. El diseño orientado a datos permite un uso muy eficiente del caché de comandos, porque el mismo código se ejecuta constantemente en él. Además, si organizamos los datos en grandes bloques adyacentes, podemos procesar los datos secuencialmente, logrando un uso casi perfecto del caché de datos y un excelente rendimiento.

Opción de optimización


Cuando pensamos en objetos o funciones, generalmente nos enfocamos en optimizar a nivel de una función o incluso un algoritmo: tratamos de cambiar el orden de las llamadas a funciones, cambiar el método de clasificación o incluso reescribir parte del código C en lenguaje ensamblador.

Tales optimizaciones son ciertamente útiles, pero si piensa primero en los datos, podemos dar un paso atrás y crear optimizaciones más ambiciosas e importantes. No olvides que el juego solo trata con la conversión de ciertos datos (recursos, entrada del usuario, estado) en algunos otros datos (comandos gráficos, nuevos estados del juego). Con este flujo de datos en mente, podemos tomar decisiones más informadas de alto nivel en función de cómo se convierten y aplican los datos. Tales optimizaciones en las técnicas OOP más tradicionales pueden ser extremadamente complejas y llevar mucho tiempo.

Modularidad


Todas las ventajas anteriores del diseño orientado a datos estaban relacionadas con el rendimiento: uso de caché, optimización y paralelización. No hay duda de que para nosotros los programadores de juegos, el rendimiento es extremadamente importante. A menudo hay un conflicto entre las técnicas que aumentan la productividad y las técnicas que promueven la legibilidad del código y la facilidad de desarrollo. Por ejemplo, si reescribimos parte del código en lenguaje ensamblador, mejoraremos el rendimiento, pero esto generalmente conduce a una disminución de la legibilidad y complica el soporte para el código.

Afortunadamente, el diseño orientado a datos beneficia tanto la productividad como la facilidad de desarrollo. Si escribe código específicamente para la conversión de datos, obtiene funciones pequeñas con un número muy pequeño de dependencias con otras partes del código. La base del código sigue siendo muy "plana", con muchas funciones "hoja" que no tienen grandes dependencias. Este nivel de modularidad y la ausencia de dependencias simplifica enormemente la comprensión, el reemplazo y la actualización del código.

Prueba


El último beneficio importante del diseño orientado a datos es su facilidad de prueba. Muchas personas saben que escribir pruebas unitarias para probar la interacción de objetos es una tarea no trivial. Necesita crear diseños y elementos de prueba indirectamente. Honestamente, esto es bastante doloroso. Por otro lado, trabajar directamente con datos, escribir pruebas unitarias es absolutamente fácil: creamos algunos datos entrantes, llamamos a la función que los convierte y verificamos si la salida coincide con los datos esperados. Y eso es todo. De hecho, esta es una gran ventaja que simplifica enormemente las pruebas de código, ya sea desarrollo basado en pruebas o escritura de pruebas unitarias después del código.

Desventajas del diseño orientado a datos


El diseño orientado a datos no es una "bala de plata" que resuelve todos los problemas en el desarrollo del juego. Realmente ayuda a escribir código de alto rendimiento y a crear programas que sean más legibles y más fáciles de mantener, pero en sí mismo tiene algunas desventajas.

El principal problema con el diseño orientado a datos: difiere de lo que la mayoría de los programadores han aprendido y acostumbrado. Requiere cambiar nuestro modelo mental del programa noventa grados y cambiar el punto de vista sobre él. Para que este enfoque se convierta en una segunda naturaleza, se requiere práctica.

Además, debido a la diferencia en los enfoques, puede causar dificultades para interactuar con el código existente escrito en un estilo de procedimiento o POO. Es difícil escribir una función por separado, pero tan pronto como pueda aplicar el diseño orientado a datos a un subsistema completo, puede obtener muchas ventajas.

Uso de diseño orientado a datos


Suficiente teoría y reseñas. ¿Cómo comenzar a implementar el método de diseño orientado a datos? Para comenzar, seleccione un área específica de su código: navegación, animaciones, colisiones u otra cosa. Más tarde, cuando la parte principal del motor del juego se centre en los datos, podrá ajustar el flujo de datos a lo largo de toda la ruta, desde el principio del cuadro hasta el final.

A continuación, es necesario identificar claramente los datos de entrada requeridos por el sistema y el tipo de datos que debe generar. Es posible que esté pensando en la terminología de OOP por ahora, solo para identificar los datos. Por ejemplo, para un sistema de animación, parte de los datos de entrada serán esqueletos, poses básicas, datos de animación y el estado actual. El resultado no es un "código de animación animada", sino datos generados por las animaciones que se reproducen actualmente. En este caso, la salida será un nuevo conjunto de poses y un estado actualizado.

Es importante retroceder y clasificar los datos entrantes en función de cómo se usan. ¿Son de solo lectura, lectura-escritura o solo escritura? Dicha clasificación ayudará a tomar decisiones sobre dónde almacenar datos y cuándo procesarlos debido a dependencias en otras partes del programa.

En esta etapa, debe dejar de pensar en los datos necesarios para una operación y comenzar a pensar en aplicarlos a docenas o cientos de elementos. Ya no tenemos un esqueleto, una pose básica y un estado actual: tenemos un bloque de cada uno de estos tipos con muchas instancias en cada uno de los bloques.

Considere cuidadosamente cómo se utilizarán los datos en el proceso de transformación de entrada a salida. Puede darse cuenta de que para transmitir datos necesita escanear un campo específico en la estructura, y luego debe usar los resultados para realizar otra pasada. En este caso, puede ser más lógico dividir este campo de origen en un bloque de memoria separado, que puede procesarse por separado, lo que hará un mejor uso de la memoria caché y preparará el código para una posible paralelización. O bien, es posible que deba vectorizar parte del código si necesita recibir datos de diferentes lugares para colocarlos en un registro vectorial. En este caso, los datos se almacenarán adyacentes para que las operaciones vectoriales se puedan aplicar directamente, sin conversiones innecesarias.

Ahora debe tener una muy buena comprensión de sus datos. Escribir código para convertirlos será mucho más fácil. Será como crear código rellenando espacios. Se sorprenderá gratamente de que el código resultó ser mucho más simple y compacto de lo que pensaba originalmente, en comparación con el mismo código OOP.

La mayoría de las publicaciones en mi blog te prepararon para este tipo de diseño. Ahora debemos tener cuidado con la forma en que se organizan los datos, hornear los datos en el formato de entrada para que puedan usarse de manera eficiente y usar enlaces sin punteros entre los bloques de datos para que puedan moverse fácilmente.

¿Hay espacio para usar OOP?


¿Significa esto que OOP es inútil y nunca debe usarse al crear programas? No puedo decir eso. Pensar en el contexto de los objetos no es dañino si estamos hablando de una sola instancia de cada objeto (por ejemplo, un dispositivo gráfico, administrador de registros, etc.), aunque en este caso el código puede implementarse en función de funciones de estilo C más simples y estáticas. datos a nivel de archivo. E incluso en esta situación, sigue siendo importante que los objetos se diseñen con énfasis en la transformación de datos.

Otra situación en la que todavía uso OOP son los sistemas GUI. Quizás esto se deba a que aquí estamos trabajando con un sistema que ya está diseñado de manera orientada a objetos, o quizás porque el rendimiento y la complejidad no son factores críticos para el código GUI. Sea como fuere, prefiero las API GUI que hacen poco uso de la herencia y maximizan la anidación (buenos ejemplos aquí son Cocoa y CocoaTouch). Es probable que para los juegos puedas escribir sistemas GUI atractivos con una orientación de datos, pero hasta ahora no los he visto.

Al final, nada te impide crear una imagen mental basada en objetos de todos modos, si prefieres pensar en el juego de esta manera. Es solo que la esencia del enemigo no ocupará un lugar físico en la memoria, sino que se dividirá en subcomponentes más pequeños, cada uno de los cuales forma parte de una gran tabla de datos de componentes similares.

El diseño orientado a datos está un poco alejado de los métodos de programación tradicionales, pero si siempre piensa en los datos y las formas necesarias de transformarlos, le brindará grandes ventajas en términos de productividad y facilidad de desarrollo.

Source: https://habr.com/ru/post/472052/


All Articles