La siguiente entrada del blog, a menos que se indique lo contrario, fue escrita por un miembro de la comunidad de Gamasutras.
Los pensamientos y opiniones expresados son los del escritor y no los de Gamasutra o su empresa matriz.
Parte 1 – Mensajería
Parte 2 – Memoria
Parte 3 – Datos &Caché
Parte 4 – Bibliotecas gráficas
Vivimos en un gran momento para ser desarrolladores. Con tal cantidad de grandes motores de grado AAA disponibles para todos, hacer juegos simples puede ser tan fácil como arrastrar y soltar. Parece que ya no hay ninguna razón para escribir un motor en estos días. Y con el sentimiento común, «Escribe juegos, no motores», ¿por qué deberías?
Este artículo está dirigido principalmente a los desarrolladores en solitario y a los equipos pequeños. Asumo cierta familiaridad con la Programación Orientada a Objetos.
Quiero darle una idea de cómo enfocar el desarrollo de un motor y utilizaré un motor ficticio simple para ilustrarlo.
¿Por qué escribir un motor?
La respuesta corta es: No lo hagas, si puedes evitarlo.
La vida es demasiado corta para escribir un motor para cada juego (Tomado del libro 3D Graphics Programming de Sergei Savchenko)
La selección actual de excelentes motores como Unity, Unreal o CryEngine son tan flexibles como uno podría esperar y pueden ser utilizados para hacer prácticamente cualquier juego. Para tareas más especializadas, existen, por supuesto, soluciones más especializadas como Adventure Game Studio o RPG Maker, por nombrar algunas. Ni siquiera el coste de los motores de grado comercial es ya un argumento.
Sólo quedan unas pocas razones de nicho para escribir tu propio motor:
- Quieres aprender cómo funciona un motor
- Necesitas ciertas funcionalidades que no están disponibles o las soluciones disponibles son inestables
- Crees que puedes hacerlo mejor / más rápido
- Quieres mantener el control del desarrollo
Todas estas son razones perfectamente válidas y si estás leyendo esto, probablemente perteneces a uno de esos campos. Mi objetivo no es entrar en un largo debate de «¿Qué motor debería usar?» o «¿Debería escribir un motor?» aquí y saltaré directamente a ello. Así que, empecemos.
Cómo fallar al escribir un Motor
Espera. Primero te digo que no escribas uno, ¿y después te explico cómo fallar? Gran introducción…
De todos modos, hay un montón de cosas a tener en cuenta antes de escribir una sola línea de código. El primer y más grande problema que tienen todos los que empiezan a escribir un motor de juego se puede resumir en esto:
¡Quiero ver algo de juego lo más rápido posible!
Sin embargo, cuanto más rápido te des cuenta de que va a pasar mucho tiempo hasta que realmente veas que ocurre algo interesante, mejor estarás escribiendo tu motor.
Forzar tu código para que muestre algún tipo de gráfico o juego tan rápido como puedas, sólo para tener alguna confirmación visual de «progreso», es tu mayor enemigo en este punto. Toma. Tu. Tiempo!
Ni siquiera pienses en empezar con los gráficos. Probablemente has leído un montón de tutoriales y libros de OpenGL / DirectX y sabes cómo renderizar un simple triángulo o sprite. Usted podría pensar que un corto fragmento de código de Rendering una pequeña malla en la pantalla es un buen lugar para empezar. No lo es.
Sí, tu progreso inicial será sorprendente. Joder, podrías estar recorriendo un pequeño nivel en Primera Persona en apenas un día copiando y pegando fragmentos de código de varios tutoriales y de Stack Overflow. Pero te garantizo que borrarás cada línea de ese código 2 días después. Peor aún, podrías incluso desanimarte de escribir un Motor, ya que no es motivador ver cada vez menos.
El segundo gran problema al que se enfrentan los desarrolladores al escribir Motores es el feature creep. A todo el mundo le gustaría escribir el santo grial de los motores. Todo el mundo quiere ese motor perfecto que puede hacer todo. Shooters en primera persona, RPGs tácticos, lo que sea. Pero el hecho es que no podemos. Todavía. Basta con mirar a los grandes nombres. Ni siquiera Unity puede realmente atender a todos los géneros de juegos perfectamente.
Ni siquiera pienses en escribir un motor que pueda hacer más de un género en tu primera vez. No lo hagas.
Por dónde empezar a escribir un motor
Escribir un motor es como diseñar un motor de verdad para un coche. Los pasos son realmente bastante obvios, asumiendo que sabes en qué Juego (o Coche) estás trabajando. Aquí están:
- Determina exactamente lo que tu motor necesita ser capaz de hacer Y lo que tu motor no necesita ser capaz de hacer.
- Organiza las necesidades en Sistemas que tu motor requerirá.
- Diseña tu Arquitectura perfecta que une todos estos Sistemas.
- Repite los pasos 1. – 3. tan a menudo como sea posible.
- Codifica.
Si (= si y sólo si) dedicas suficiente tiempo y esfuerzo a los pasos 1. – 4. y el diseño del juego no cambia repentinamente de un juego de terror a una máquina tragaperras (léase: Silent Hill), codificar será un esfuerzo muy agradable. La codificación estará lejos de ser fácil, pero perfectamente manejable, incluso por los desarrolladores en solitario.
Esta es la razón por la que este artículo es principalmente sobre los pasos 1. – 4. Piense en el paso 5. como «Rellenar los espacios en blanco. 50.000 LOC de espacios en blanco».
La parte más crucial de todo esto es el Paso 3. La parte más crucial de todo esto es el Paso 3. ¡Aquí centraremos la mayor parte de nuestros esfuerzos! Identificar las necesidades y las carencias
Todos estos pasos pueden parecer bastante triviales al principio. Pero en realidad no lo son. Podrías pensar que el Paso 1 del proceso de desarrollo de un motor de First Person Shooter puede reducirse a esto:
Necesito cargar un Nivel, el arma del Jugador, algunos Enemigos con IA. Hecho, en el paso 2.
Si fuera tan fácil. La mejor manera de ir sobre el Paso 1 es ir a través de todo el juego clic por clic, acción por acción de hacer clic en el icono en el escritorio, a golpear la tecla de salida después de rodar los créditos. Haz una lista, una gran lista de lo que necesitas. Haga una Lista de lo que definitivamente no necesita.
Esto probablemente será así:
Inicio el juego y va directamente al Menú Principal. ¿El menú utilizará una imagen estática? ¿Una escena cortada? ¿Cómo puedo controlar el menú principal, el ratón? ¿Teclado? ¿Qué tipo de elementos GUI necesito para el Menú Principal? ¿Botones, formularios, barras de desplazamiento? ¿Qué pasa con la música?
Y eso son sólo macro-conciertos. Entra lo más detallado posible. Decidir que necesita botones está bien y es bueno, pero también conciderar lo que un botón puede hacer.
Quiero que los botones tengan 4 estados, Up, Hover, Down, Disabled. ¿Necesitaré sonido para los botones? ¿Qué hay de los efectos especiales? ¿Son animados en el estado de reposo?
Si su lista de necesidades y carencias sólo contiene unos 10 elementos al final del menú principal, ha hecho algo mal.
En esta etapa, lo que está haciendo es simular el motor en su cerebro y escribir lo que hay que hacer. El paso 1 será más claro en cada iteración, no se preocupe si se le escapa algo la primera vez.
Paso 2. Organice las necesidades en Sistemas
Así que tiene sus listas de cosas que necesita y no necesita. Es el momento de organizarlas. Obviamente, las cosas relacionadas con la GUI, como los botones, irán a algún tipo de sistema de GUI. Los elementos relacionados con el renderizado van en el Sistema Gráfico / Motor.
De nuevo, como en el Paso 1, decidir qué va donde será más obvio en su segunda iteración, después del Paso 3. Para la primera pasada, Agruparlos lógicamente como en el ejemplo anterior.
La mejor Referencia sobre «qué va donde» y «qué hace qué» es sin duda el Libro Game Engine Architecture de Jason Gregory.
Empieza a agrupar las funcionalidades. Empieza a pensar en formas de combinarlas. No necesitas Camera->rotateYaw(float yaw)
y Camera->rotatePitch(float pitch)
si puedes combinarlas en Camera->rotate(float yaw, float pitch)
. Mantenga la sencillez. Demasiada funcionalidad (recuerde, feature creep) le perjudicará más adelante.
Piense en qué funcionalidad necesita ser expuesta públicamente y qué funcionalidad sólo necesita residir dentro del propio Sistema. Por ejemplo, su Renderizador necesita ordenar todos los Sprites transparentes antes de dibujar. La función para ordenar estos sprites, sin embargo, no necesita ser expuesta. Usted sabe que necesita ordenar los Sprites transparentes antes de dibujar, no necesita que un Sistema externo le diga esto.
Paso 3. La arquitectura (O, el artículo real)
También podríamos haber empezado el artículo aquí. Esta es la parte interesante e importante.
Una de las Arquitecturas más sencillas que puede tener tu motor es poner cada Sistema en una Clase y hacer que el Bucle Principal del Juego llame a sus subrutinas. Podría ser algo así:
while(isRunning)
{
Input->readInput();
isRunning = GameLogic->doLogic();
Camera->update();
World->update();
GUI->update();
AI->update();
Audio->play();
Render->draw();
}
Parece perfectamente razonable al principio. Tienes todo lo básico cubierto, Input -> procesando Input -> Output.
Y de hecho, esto será suficiente para un simple Juego. Pero será un dolor para mantener. La razón de esto debería ser obvia: Dependencias.
Cada Sistema debe comunicarse con otros Sistemas de alguna manera. No tenemos ningún medio para hacer eso en nuestro Bucle de Juego anterior. Por lo tanto el ejemplo indica claramente, que cada Sistema debe tener alguna Referencia de los otros Sistemas para poder hacer algo significativo. Nuestra GUI y la Lógica del Juego deben saber algo sobre nuestro Input. Nuestro Renderizador debe saber algo sobre nuestra Lógica de Juego para poder mostrar algo significativo.
Esto nos llevará a esta maravilla arquitectónica:
Si huele a Spaghetti, es Spaghetti. Definitivamente no es lo que queremos. Sí, es fácil y rápido de codificar. Sí, tendremos resultados aceptables. Pero mantenible, no lo es. Cambiar un pequeño trozo de código en alguna parte puede tener efectos devastadores en todos los demás sistemas sin que lo sepamos.
Además, siempre habrá código al que muchos sistemas necesiten acceder. Tanto el GUI como el Renderer necesitan hacer llamadas a Draw o al menos tener acceso a algún tipo de Interfaz para manejar esto por nosotros. Sí, podríamos dar a cada Sistema el poder de llamar a las funciones OpenGL / DirectX directamente, pero terminaremos con muchas redundancias.
Podríamos resolver esto recogiendo todas las funciones de dibujo dentro del Sistema Renderizador y llamarlas desde el sistema GUI. Pero entonces el Sistema de Renderización tendrá funciones específicas para la GUI. Estas no tienen lugar en el Renderizador y por lo tanto es contrario al Paso 1 y 2. Decisiones, Decisiones.
Así que lo primero que debemos considerar es dividir nuestro Motor en Capas.
La Lasaña del Motor
Es mejor la lasaña que los espaguetis. Al menos desde el punto de vista de la programación. Ciñéndonos a nuestro Ejemplo de Renderizador, lo que queremos es llamar a funciones OpenGL / DirectX sin llamarlas directamente en el Sistema. Esto huele a Wrapper. Y en gran parte lo es. Recogemos toda la funcionalidad de dibujo dentro de otra clase. Estas clases son aún más básicas que nuestros Sistemas. Llamemos a estas nuevas clases el Framework.
La idea detrás de esto es abstraer muchas de las llamadas al API de bajo nivel y formarlas en algo adaptado a nuestro juego. No queremos establecer el Vertex Buffer, establecer el Index Buffer, establecer las Texturas, habilitar esto, deshabilitar aquello sólo para hacer una simple llamada de dibujo en nuestro Sistema Renderizador. Vamos a poner todas esas cosas de bajo nivel en nuestro Framework. Y llamaré a esta parte del Framework «Draw». ¿Por qué? Bueno, todo lo que hace es configurar todo para dibujar y luego dibujarlo. No se preocupa por lo que dibuja, dónde lo hace, por qué lo hace. Eso se lo deja al Sistema Renderizador.
Esto puede parecer algo raro, queremos velocidad en nuestro motor ¿verdad? Más capas de abstracción = menos velocidad.
Y tendrías razón, si fueran los años 90. Pero necesitamos la mantenibilidad y podemos vivir con la pérdida de velocidad apenas perceptible para la mayoría de las partes.
¿Cómo debería entonces diseñarse nuestro Draw Framework? En pocas palabras, como nuestra propia pequeña API. SFML es un gran ejemplo de esto.
Cosas importantes a tener en cuenta:
- Mantenerlo bien documentado. ¿Qué funciones tenemos? Cuándo se pueden llamar? ¿Cómo se llaman?
- Mantenerlo simple. Funciones fáciles como drawMesh(Mesh* oMesh) o loadShader(String sPath) te harán feliz a largo plazo.
- Mantenlo funcional. No seas demasiado específico. en lugar de
drawButtonSprite
, ten unadrawSprite
función y deja que el Caller se encargue del resto.
¿Qué ganamos? Mucho:
- Sólo tenemos que configurar nuestro Framework una vez y podemos usarlo en todos los Sistemas que necesitemos (GUI, Renderer….)
- Podemos cambiar fácilmente las API’s subyacentes si lo deseamos, sin reescribir cada Sistema. ¿Cambiar de OpenGL a DirectX? No hay problema, sólo hay que reescribir la clase del Framework.
- Mantiene el código de nuestros sistemas limpio y ajustado.
- Tener una interfaz bien documentada significa que una persona puede trabajar en el Framework, mientras que otra trabaja en la capa del sistema.
Probablemente acabemos con algo así:
Mi regla general de lo que entra en el Framework es bastante simple. Si necesito llamar a una librería externa (OpenGL, OpenAL, SFML…) o tengo estructuras de datos/algoritmos que todo sistema necesita, debería hacerlo en el Framework.
Ahora tenemos nuestra primera capa de lasaña hecha. Pero todavía tenemos esta enorme bola de espaguetis por encima de ella. Vamos a abordar eso a continuación.
Mensajería
El Gran Problema sin embargo sigue siendo. Nuestros sistemas siguen estando interconectados. No queremos eso. Hay una multitud de maneras de lidiar con este problema. Eventos, Mensajes, Clases Abstractas con Punteros a Funciones (Qué esotérico)…
Conservemos los Mensajes. Este es un concepto simple que sigue siendo muy popular en la programación de GUI. También es muy adecuado como ejemplo fácil para nuestro Motor.
Funciona como un Servicio Postal. La empresa A envía un mensaje a la empresa B y pide que se haga algo. Estas empresas no necesitan ninguna conexión física. La empresa A simplemente asume que la empresa B lo hará en algún momento. Pero, por ahora, a la empresa A no le importa realmente cuándo o cómo lo haga la empresa B. Sólo necesita hacerlo. La empresa B podría incluso decidir redirigir el mensaje a las empresas C y D y dejar que se encarguen de ello.
Podemos ir un paso más allá, la empresa A ni siquiera necesita enviarlo a alguien en concreto. La empresa A simplemente publica la carta y cualquiera que se sienta responsable la procesará. De esta forma la empresa C y D pueden tramitar directamente la solicitud.
Obviamente, las empresas igualan nuestros Sistemas. Veamos un ejemplo sencillo:
- El sistema de entrada notifica que se ha pulsado «A»
- La entrada traduce que la pulsación de la tecla «A» significa «Abrir inventario» y envía un mensaje que contiene «Abrir inventario»
- GUI maneja el Mensaje y abre la Ventana de Inventario
- Game Logic maneja el Mensaje y pausa el Juego
.
Input ni siquiera le importa lo que se está haciendo con su Mensaje. A GUI no le importa que Game Logic también procese el mismo Mensaje. Si todos estuvieran acoplados, Input necesitaría llamar a una función en el Sistema GUI y a una función en Game Logic. Pero ya no es necesario. Hemos sido capaces de desacoplar esto con éxito utilizando Mensajes.
¿Cómo se ve un Mensaje? Al menos debería tener algún Tipo. Por ejemplo, abrir el inventario podría ser algún enum llamado OPEN_INVENTORY
. Esto es suficiente para mensajes simples como este. Mensajes más avanzados que necesiten incluir datos necesitarán alguna forma de almacenar esos datos. Hay una multitud de maneras de lograr esto. La más fácil de implementar es usar una simple estructura de mapa.
¿Pero cómo enviamos los Mensajes? A través de un Bus de Mensajes, por supuesto!
¿No es hermoso? Se acabaron los espaguetis, sólo la vieja y sencilla lasaña. Deliberadamente puse nuestra Lógica de Juego en el otro lado del Bus de Mensajes. Como puedes ver, no tiene conexión con la capa del Framework. Esto es importante para evitar cualquier tentación de «sólo llamar a esa función». Créeme, querrás hacerlo tarde o temprano, pero rompería nuestro diseño. Ya tenemos suficientes Sistemas tratando con el Framework, no hay necesidad de hacer eso en nuestra Lógica de Juego.
El Bus de Mensajes es una simple Clase con Referencias a cada Sistema. Si tiene un Mensaje en cola, el Bus de Mensajes lo envía a cada Sistema a través de una simple llamada handleMessage(Msg msg)
. A cambio, cada Sistema tiene una referencia al Bus de Mensajes para poder enviarlos. Obviamente, esto puede ser almacenado internamente o pasado como un argumento de la función.
Todos nuestros Sistemas deben, por tanto, heredar o ser de la siguiente forma:
class System
{
public:
void handleMessage(Msg *msg);
{
switch(msg->type)
{
//// Example
//case Msg::OPEN_INVENTORY:
// break;
}
}
private:
MessageBus *msgBus;
//// Usage: msgBus->postMessage(msg);
}
(Sí, sí, punteros en bruto…)
De repente, nuestro Bucle de Juego cambia para simplemente dejar que el Bus de Mensajes envíe alrededor de los Mensajes. Todavía tendremos que actualizar periódicamente cada Sistema a través de algún tipo de llamada update()
. Pero la comunicación será manejada de manera diferente.
Sin embargo, al igual que con nuestros Frameworks, el uso de Mensajes crea una sobrecarga. Esto ralentizará un poco el motor, no nos engañemos. Pero no nos importa. Queremos un diseño limpio y simple. Una arquitectura limpia y simple!
¿Y lo mejor? Conseguimos cosas increíbles de forma gratuita!
La Consola
Cada Mensaje es más o menos una llamada a una función. Y cada mensaje se envía a casi todas partes. ¿Qué pasa si tenemos un sistema que simplemente imprime cada mensaje que se envía en alguna ventana de salida? ¿Y si este Sistema también puede enviar los Mensajes que escribimos en esa ventana?
Sí, acabamos de dar a luz a una Consola. Y todo lo que nos ha costado son unas pocas líneas de código. Me quedé boquiabierto cuando vi esto por primera vez en acción. Ni siquiera está ligada a nada, simplemente existe.
Una consola es obviamente muy útil mientras se desarrolla el juego y podemos simplemente quitarla en la Release, si no queremos que el Jugador tenga ese tipo de acceso.
Cinemáticas en el Juego, Repeticiones & Depuración
¿Y si falseamos los Mensajes? ¿Y si creamos un nuevo Sistema que simplemente envíe Mensajes a una hora determinada? Imagina que envía algo como MOVE_CAMERA
, seguido de ROTATE_OBJECT
.
Y Voila, tenemos In-Game Cinematics.
¿Y si simplemente grabamos los Mensajes de Entrada que se enviaron durante el Juego y los guardamos en un archivo?
Y Voila, tenemos Replays.
¿Qué pasa si simplemente grabamos todo lo que el jugador hace, y cuando el juego se bloquea, hacemos que nos envíen esos archivos de datos?
Y Voilà, tenemos una copia exacta de las acciones de los jugadores que llevaron al bloqueo.
Multi-Hilo
Multi-Hilo? Sí, Multi-Threading. Hemos desacoplado todos nuestros sistemas. Esto significa, que pueden procesar sus Mensajes cuando quieran, como quieran y lo más importante, donde quieran. Podemos hacer que nuestro Bus de Mensajes decida en qué hilo debe procesar un Mensaje cada Sistema -> Multi-Threading
Fijación de la Tasa de Cuadros
¿Tenemos demasiados Mensajes para procesar este Cuadro? No hay problema, guardémoslos en la Cola del Bus de Mensajes y enviémoslos en la siguiente Trama. Esto nos dará la oportunidad de asegurar que nuestro Juego se ejecute a 60 FPS sin problemas. Los jugadores no notarán que la IA tarda más en «pensar». Sin embargo, notarán caídas en la tasa de fotogramas.
Los mensajes son geniales.
Es importante que documentemos meticulosamente cada Mensaje y sus parámetros. Tratarlo como una API. Si haces esto bien, cada desarrollador puede trabajar en diferentes Sistemas sin romper nada. Incluso si un sistema está fuera de línea o en construcción, el juego seguirá funcionando y puede ser probado. ¿No hay sistema de audio? No pasa nada, seguimos teniendo visuales. Sin Renderizador, está bien, podemos usar la Consola…
Pero los Mensajes no son perfectos. Tristemente.
A veces, SÍ queremos saber el resultado de un Mensaje. A veces SÍ necesitamos que se procesen inmediatamente. Necesitamos encontrar opciones viables. Una solución a esto es tener un Speedway. Además de una simple función postMessage
, podemos implementar una función postImmediateMessage
que se procese inmediatamente. El manejo de los mensajes de retorno es mucho más fácil. Esos son enviados a nuestra función handleMessage
tarde o temprano. Sólo tenemos que recordar esto al publicar un mensaje.
Los mensajes inmediatos obviamente rompen el Multi-Threading y el Frame Rate Fixing si se hace en exceso. Por lo tanto es vital restringirse para limitar su uso.
Pero el mayor Problema de este Sistema es la latencia. No es la arquitectura más rápida. Si usted está trabajando en un First Person Shooter con twitch como los tiempos de respuesta, esto podría ser un interruptor de acuerdo.
Volviendo al diseño de nuestra arquitectura
Hemos decidido utilizar sistemas y un bus de mensajes. Sabemos exactamente cómo queremos estructurar nuestro Motor.
Es el momento del Paso 4 de nuestro proceso de Diseño. Iteración. Algunas funciones pueden no caber dentro de ningún Sistema, debemos encontrar una solución. Algunas funciones necesitan ser llamadas extensamente y atascarían el Bus de Mensajes, debemos encontrar una solución.
Esto lleva tiempo. Pero vale la pena a largo plazo.
Por fin ha llegado el momento de codificar!
Paso 4. ¿Dónde empezar a codificar?
Antes de empezar a codificar, lee el libro/artículo Game Programming Patterns de Robert Nystrom.
Aparte de eso, he esbozado una pequeña hoja de ruta que podrías seguir. No es, ni mucho menos, el mejor camino, pero es productivo.
- Si vas a utilizar un motor de tipo bus de mensajes, considera codificar primero la consola y el bus de mensajes. Una vez que estos estén implementados, puede fingir la existencia de cualquier Sistema que aún no haya sido codificado. Usted tendrá un control constante sobre todo el motor en cada etapa de desarrollo.
- Considere pasar a la interfaz gráfica de usuario a continuación, así como la funcionalidad de dibujo necesaria dentro del marco. Una sólida GUI emparejada con la consola le permitirá falsear todos los demás sistemas de forma aún más fácil. Las pruebas serán una brisa.
- Lo siguiente debería ser el Framework, al menos su interfaz. La funcionalidad puede seguir más tarde.
- Por último, pasa a los otros Sistemas, incluyendo el Juego.
Te darás cuenta de que, en realidad, renderizar cualquier cosa relacionada con el Juego puede ser lo último que hagas. Y eso es algo bueno. Se sentirá mucho más gratificante y lo mantendrá motivado para terminar los toques finales de su Motor.
Sin embargo, su Diseñador de Juegos podría dispararle durante este proceso. Probar el juego a través de los comandos de la consola es tan divertido como jugar al Counter Strike a través del IRC.
Conclusión
¡Tómate tu tiempo para encontrar una arquitectura sólida y quédate con ella! Ese es el consejo que espero que te lleves de este artículo. Si haces esto, serás capaz de construir un Motor perfectamente fino y mantenible al final del día. O siglo.
Personalmente, disfruto más escribiendo Motores que haciendo todo eso del Gameplay. Si tienes alguna pregunta, no dudes en contactar conmigo a través de Twitter @Spellwrath. Actualmente estoy terminando otro Motor usando los métodos que he descrito en este Artículo.
Puedes encontrar la Parte 2 aquí.