Cómo hicimos nuestra pequeña Unidad desde cero



Nuestra empresa tiene su propio motor de juego, que se utiliza para todos los juegos desarrollados. Proporciona todas las funciones básicas importantes:

  • renderizado
  • trabajar con SDK;
  • trabajar con el sistema operativo;
  • con red y recursos.

Sin embargo, carecía de lo que Unity valora tanto: un sistema conveniente para organizar escenas y objetos de juego, así como editores para ellos.

Aquí quiero contar cómo presentamos todas estas comodidades y a qué llegamos.

Que es ahora


Ahora tenemos una apariencia de un sistema de componentes en Unity con todos los subsistemas y editores importantes. Sin embargo, dado que procedimos de las necesidades de nuestros proyectos específicos, existen diferencias bastante significativas.

Tenemos objetos visuales que se almacenan en escenas. Estos objetos consisten en nodos que están organizados en una jerarquía y cada nodo puede tener una serie de entidades, como:

  • Transformar: transformación del nodo;
  • Componente: se dedica a la representación y solo puede haber uno o ninguno. Los componentes son sprites, mallas, partículas y otras entidades que se pueden mostrar. El equivalente más cercano a Unity es Renderer;
  • Comportamiento: responsable del comportamiento, y puede haber varios. Este es un análogo directo de MonoBehaviour en Unity, cualquier lógica está escrita en ellos;
  • La ordenación es una entidad responsable del orden en que se muestran los nodos en una escena. Dado que nuestro sistema debería haber sido fácil de integrar en los juegos que ya se están ejecutando, con la lógica existente y diversa para mostrar objetos, era necesario poder integrar nuevas entidades en las antiguas. Por lo tanto, la clasificación le permite transferir el control sobre el orden de visualización al código externo.

Al igual que en Unity, los programadores crean su componente, comportamiento u ordenamiento. Para hacer esto, solo escriba una clase, redefina los eventos necesarios (Actualización, Inicio, etc.) y marque los campos necesarios de una manera especial. En UnrealEngine, esto se hace con macros, y decidimos usar etiquetas en los comentarios.

/// @category(VSO.Basic) class SpriteComponent : public MaterialComponent { VISUAL_CLASS(MaterialComponent) public: /// @getter const std::string& GetId() const; /// @setter void SetId(const std::string& id); protected: void OnInit() override; void Draw() override; protected: /// @property Color _color = Color::WHITE; /// @property Sprite _sprite; }; 

Además en la clase, teniendo en cuenta las etiquetas, se generará todo el código, que es necesario para guardar y cargar datos, para el trabajo de los editores, para admitir la clonación y otras funciones pequeñas.

La serialización automática y la generación de editores son compatibles no solo para las entidades que se almacenan en un objeto visual, sino también para cualquier clase. Para hacer esto, es suficiente heredarlo de la clase especial Serializable y marcar las propiedades necesarias con etiquetas. Y si desea que las instancias de la clase sean activos completos (un análogo de ScriptableObject de Unity), la clase debe heredarse de la clase Asset.

Como resultado, la biblioteca brinda la oportunidad de desarrollar rápidamente una nueva funcionalidad. Y ahora parte del trabajo para desarrollar el juego, por ejemplo, crear efectos, diseño de interfaz de usuario, diseño de escenas de juego, puede transferirse a especialistas que pueden manejarlo mejor que los programadores.

Bloques principales




Generación de código


Para que muchos sistemas funcionen, debe escribir una gran cantidad de código de rutina, lo cual es necesario debido a la falta de reflexión en C ++ ( reflexión : la capacidad de acceder a información sobre los tipos en el código del programa). Por lo tanto, generamos la mayor parte de este código técnico.

Un generador es un conjunto de scripts de Python que analizan los archivos de encabezado y generan el código necesario en función de ellos. Para configuraciones de generación flexibles, se usan etiquetas especiales en los comentarios.

Podemos generar código para los siguientes subsistemas:

  • Serialización: se utiliza para guardar / cargar datos desde el disco o cuando se transmite a través de una red. Se considerará con más detalle más adelante.
  • Enlaces para la biblioteca de reflexión: se utilizan para mostrar automáticamente el editor a los datos. Se discutirá en el capítulo sobre el editor.
  • Código para entidades de clonación: se utiliza para clonar entidades en el editor y en el juego.
  • Código para nuestro reflejo de tiempo de ejecución ligero.

Aquí se puede encontrar un ejemplo del código generado para una clase .

Analizando c ++


Casi todas las opciones para resolver el problema de analizar los archivos de encabezado llevaron a analizar el código con clang. Pero después de los experimentos, quedó claro que la velocidad de tal solución no nos convenía en absoluto. Además, el poder que proporcionaba el sonido metálico no era necesario para nosotros.

Por lo tanto, se encontró otra solución: CppHeaderParser . Esta es una biblioteca de un solo archivo de Python que puede leer archivos de encabezado. Es muy primitivo, no sigue #include, omite macros, no analiza caracteres y funciona muy rápidamente.

Todavía lo usamos hasta el día de hoy, sin embargo, tuvimos que hacer una buena cantidad de ediciones para corregir errores y expandir nuestras capacidades, en particular, se agregó soporte para innovaciones de C ++ 17.

Queríamos evitar malentendidos relacionados con la incertidumbre del estado de generación del código. Por lo tanto, se decidió que la generación debería ocurrir de forma completamente automática. Usamos CMake, en el cual la generación comienza en cada compilación (no pudimos configurar la generación para comenzar solo cuando cambian las dependencias). Para que esto no tome mucho tiempo y no moleste, almacenamos un caché con el resultado de analizar todos los archivos y el contenido del directorio. Como resultado, el inicio inactivo de la generación de código lleva solo unos segundos.

Generador de código


Con la generación, todo es más simple. Hay muchas bibliotecas para generar cualquier cosa desde una plantilla. Elegimos Templite + , ya que es muy pequeño, tiene la funcionalidad necesaria y funciona correctamente.

Había dos enfoques para la generación. La primera versión contenía muchas condiciones, verificaciones y otro código, por lo que las plantillas en sí mismas eran mínimas, y la mayor parte de la lógica y el texto producido estaban en código python. Era conveniente, porque en el código de Python es más conveniente escribir que en las plantillas, y era fácil atornillar la lógica arbitrariamente complicada. Sin embargo, esto también fue terrible, porque el código de Python, mezclado con una gran cantidad de líneas de código C ++, era inconveniente para leer o escribir. Los generadores de pitón usados ​​simplificaron la situación, pero no eliminaron el problema en su conjunto.

Por lo tanto, la versión actual de la generación se basa en plantillas, y el código de Python simplemente prepara los datos necesarios y ahora se ve mucho mejor.

Serialización


Para la serialización, se consideraron varias bibliotecas: protobuf, FlexBuffers, cereales, etc.

Las bibliotecas con generación de código (Protobuf, FlatBuffers y otras) no encajaban, porque tenemos estructuras escritas a mano y no hay forma de integrar las estructuras generadas en el código de usuario. Y duplicar el número de clases solo para la serialización es demasiado derrochador.

La biblioteca de cereales parecía ser el mejor candidato: buena sintaxis, implementación clara, es conveniente generar código de serialización. Sin embargo, su formato binario no nos convenía, al igual que el formato de la mayoría de las otras bibliotecas. Los requisitos de formato importantes eran la independencia del hardware (los datos deberían ser legibles independientemente del orden de bytes y la profundidad de bits) y el formato binario debería ser conveniente para escribir desde python.

Escribir un archivo binario desde python era importante, ya que queríamos tener un script universal independiente de la plataforma y del proyecto que convirtiera los datos de una vista de texto a una binaria. Por lo tanto, escribimos un script que resultó ser una herramienta de serialización muy conveniente.

La idea principal fue tomada del cereal, se basa en archivos básicos para leer y escribir datos. A partir de ellos se crean diferentes herederos que implementan el registro en diferentes formatos: xml, json, binario. Y el código de serialización es generado por clases y utiliza estos archivos para escribir datos.



El editor


Utilizamos la biblioteca ImGui para editores, en la que escribimos todas las ventanas principales del editor: contenido de la escena, visor de archivos y activos, inspector de activos, editor de animación, etc.

El código del editor principal está escrito a mano, pero para ver y editar las propiedades de clases específicas, utilizamos la biblioteca rttr, la agrupación generada por él y el código del inspector generalizado que puede funcionar con rttr.

Biblioteca de reflexiones - rttr


Para organizar la reflexión en C ++, se eligió la biblioteca rttr. No requiere intervención en las clases mismas, tiene una API conveniente y comprensible, tiene soporte para colecciones y envoltorios sobre tipos (como punteros inteligentes) con la capacidad de registrar sus envoltorios y le permite hacer lo que sea necesario (crear tipos, iterar sobre miembros de la clase, cambiar propiedades, etc. métodos de llamada, etc.).

También le permite trabajar con punteros, como los campos regulares, y utiliza el patrón de objeto nulo, lo que simplifica enormemente el trabajo con él.

La desventaja de la biblioteca es que es voluminosa y no muy rápida, por lo que la usamos solo para editores. En el código del juego para trabajar con los parámetros de los objetos, por ejemplo, para un sistema de animación, utilizamos la biblioteca de reflexión más simple de nuestra propia producción.

La biblioteca rttr requiere escribir un enlace con la declaración de todos los métodos y propiedades de la clase. Este enlace se genera a partir del código de Python para todas las clases que necesitan soporte de edición. Y debido al hecho de que rttr puede agregar metadatos para cualquier entidad, el generador de código puede establecer diferentes configuraciones para los miembros de la clase: información sobre herramientas, parámetros de límites de valores aceptables para campos numéricos, un inspector especial para el campo, etc. Estos metadatos se usan en el inspector para mostrar la interfaz de edición .

→ Aquí se puede encontrar un código de ejemplo para declarar una clase en rttr

Inspector


El código de los propios editores rara vez funciona con rttr directamente. La capa más utilizada es que el objeto puede dibujar un inspector ImGui para ello. Este es un código escrito a mano que funciona con datos de rttr y dibuja controles ImGui para él.

Para personalizar la visualización de la interfaz de edición de datos, se utilizan los metadatos especificados durante el registro en rttr. Admitimos todos los tipos primitivos, colecciones, es posible crear objetos almacenados por valor y por puntero. Si el miembro de la clase es un puntero a la clase base, puede seleccionar un heredero específico durante la creación.

Además, el código del inspector asume el soporte de las operaciones de cancelación: al cambiar los valores, se crea un comando para cambiar los datos, que luego se pueden revertir.

Hasta ahora, no tenemos un sistema para determinar los cambios atómicos con la capacidad de verlos y guardarlos. Esto significa que no tenemos soporte para guardar las propiedades modificadas del objeto en la escena y aplicar estos cambios después de cargar el prefabricado. Y tampoco hay creación automática de pistas animadas cuando se cambian las propiedades de un objeto.

Windows y editores


En este momento, se han creado muchos subsistemas y editores diferentes sobre la base de nuestros editores, generación de código y sistemas de creación de activos:

  • El sistema de interfaz del juego proporciona un diseño flexible y conveniente e incluye todos los elementos de interfaz necesarios. Se hizo un sistema de secuencias de comandos visuales del comportamiento de la ventana para ella.
  • El sistema para cambiar el estado de las animaciones es similar al editor de estado en animaciones en Unity, pero difiere ligeramente en principio de operación y tiene una aplicación más amplia.
  • El diseñador de misiones y eventos le permite personalizar de manera flexible los eventos, misiones y tutoriales del juego, casi sin la participación de programadores.

Al desarrollar todos estos subsistemas y editores, miramos de cerca a Unity , Unreal Engine e intentamos sacar lo mejor de ellos. Y algunos de estos subsistemas se hacen del lado de los proyectos de juegos.

Para resumir


En conclusión, me gustaría describir cómo se llevó a cabo el desarrollo. La primera versión de trabajo fue hecha e integrada en algunos proyectos de juegos por un par de personas en solo dos meses. Todavía no ha tenido generación de código, y la abundancia de editores que hay ahora. Al mismo tiempo, era una versión funcional, con la que comenzó el movimiento hacia adelante. No se puede decir que en ese momento esto correspondía al vector principal del desarrollo del motor, todo se basaba en el entusiasmo de varias personas y una comprensión clara de la necesidad y la corrección de lo que hicimos.

Todo el desarrollo posterior se llevó a cabo de manera muy activa y evolutiva, paso a paso, pero siempre teniendo en cuenta los intereses de los proyectos de juegos. En este momento, más de diez personas están trabajando en el desarrollo de "nuestra pequeña Unidad" y el desarrollo de una nueva versión ya no es tan rápido como lo fue al principio.

Sin embargo, hemos logrado excelentes resultados en solo un par de años y no vamos a parar. Deseo que avance hacia lo que cree que es correcto e importante para usted y para la empresa en general.

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


All Articles