Escrevendo uma Game Engine do Raspadinha – Parte 1: Mensagens

author
20 minutes, 42 seconds Read

O seguinte post do blog, a menos que de outra forma observado, foi escrito por um membro da comunidade Gamasutra.
Os pensamentos e opiniões expressos são do escritor e não do Gamasutra ou de sua empresa mãe.

Parte 1 – Mensagens
Parte 2 – Memória
Parte 3 – Dados & Cache
Parte 4 – Bibliotecas Gráficas

>

Vivemos em um ótimo momento para sermos desenvolvedores. Com uma quantidade tão grande de grandes motores AAA disponíveis para todos, fazer jogos simples pode ser tão fácil quanto arrastar e soltar. Parece não haver mais razão alguma para escrever um motor hoje em dia. E com o sentimento comum, “Write Games, not Engines”, por que você deveria?

Este artigo é dirigido principalmente a desenvolvedores solo-developers e pequenas equipes. Eu assumo alguma familiaridade com Programação Orientada a Objetos.

Quero dar-lhe uma visão sobre como abordar o desenvolvimento de Motores e vou usar um simples Motor fictício para ilustrar isto.

Porquê escrever um Motor?

A resposta é curta: Não o faça, se conseguir evitá-lo.

A vida é abreviar para escrever uma engine para cada jogo (Extraído do livro Programação Gráfica 3D de Sergei Savchenko)

A seleção atual de excelentes Motores como Unity, Unreal ou CryEngine são tão flexíveis quanto se poderia esperar e podem ser usados para fazer praticamente qualquer jogo. Para tarefas mais especializadas, existem naturalmente soluções mais especializadas como Adventure Game Studio ou RPG Maker, só para citar algumas. Já nem sequer o custo dos Motores de grau comercial é um argumento.

Existem apenas algumas razões de nicho para escrever o seu próprio motor:

  • Você quer aprender como funciona um motor
  • Você precisa de certas funcionalidades que não estão disponíveis ou as soluções disponíveis são instáveis
  • Você acredita que pode fazer melhor / mais rápido
  • Você quer ficar no controle do desenvolvimento

Todos estes são motivos perfeitamente válidos e se você está lendo isto, você provavelmente pertence a um desses campos. O meu objectivo não é entrar num longo debate “Que motor devo usar?” ou “Devo escrever um motor?” aqui e saltarei logo para dentro dele. Então, vamos começar.

Como falhar ao escrever um Motor

Espere. Primeiro digo-vos para não escreverem um, depois explico-vos como falhar? Grande Introdução…

Ainda, há muitas coisas a conciderar antes mesmo de escrever uma única linha de código. O primeiro e maior problema que todos que começam a escrever um motor de jogo têm pode ser resumido a isto:

Eu quero ver alguma jogabilidade o mais rápido possível!

No entanto, quanto mais rápido você perceber que vai levar muito tempo até que você realmente veja algo interessante acontecer, melhor você estará escrevendo seu motor.

Forçando o seu código a mostrar alguma forma de gráficos ou jogabilidade o mais rápido possível, apenas para ter alguma confirmação visual de “progresso”, é o seu maior inimigo neste momento. Toma. Seu. Tempo!

Não pense sequer em começar com os gráficos. Você provavelmente já leu muito de OpenGL / DirectX tutoriais e livros e sabe como renderizar um simples triângulo ou sprite. Você pode pensar que um pequeno trecho de código de Rendering um pouco de malha na tela é um bom lugar para começar. Não é.

Sim, o seu progresso inicial será incrível. Diabos, você pode estar correndo um pouco de nível em Primeira Pessoa em apenas um dia copiando trechos de código de vários tutoriais e Stack Overflow. Mas eu te garanto, você vai apagar cada linha desse código 2 dias depois. Pior ainda, você pode até desanimar de escrever um Engine, pois não é motivador ver cada vez menos.

O segundo grande problema que os desenvolvedores enfrentam enquanto escrevem Motores é o arrepio de recursos. Todos adorariam escrever o Santo Graal dos Motores. Todo mundo quer aquele Motor perfeito que pode fazer tudo. Atiradores em Primeira Pessoa, RPGs Tácticos, tudo o que quiseres. Mas o simples fato permanece, não podemos. No entanto… Olha só para os grandes nomes. Nem mesmo a Unidade pode realmente atender perfeitamente a todos os gêneros de Jogos.

Não pense sequer em escrever um Motor que possa fazer mais de um gênero na sua primeira tentativa. Não o faça!

>

Onde começar a escrever um Motor

Escrever um Motor é como escrever um Motor real para um carro. Os passos são realmente bastante óbvios, assumindo que você sabe em que Jogo (ou Carro) você está trabalhando. Aqui estão eles:

  1. Ponha exactamente aquilo de que o seu motor precisa para ser capaz E aquilo de que o seu motor não precisa para ser capaz.
  2. Organizar as necessidades em Sistemas que o seu motor vai precisar.
  3. Desenhar a sua Arquitectura perfeita que liga todos estes Sistemas.
  4. Repetir os passos 1. – 3. o mais frequentemente possível.
  5. Código.

Iff (= se e só se) você gastar tempo e esforço suficiente nos passos 1. – 4. e o Design do Jogo não muda de repente de um Jogo de Terror para uma Slot Machine (leia: Silent Hill), a codificação será um esforço muito agradável. A codificação ainda estará longe de ser fácil, mas perfeitamente gerenciável, mesmo por Solo-Developers.

Esta é a razão pela qual este artigo é principalmente sobre os passos 1. – 4. Pense no passo 5. como “Filling in the Blanks” (Preenchendo os espaços em branco). 50.000 LOC de Blanks”.

A parte mais crucial de tudo isto é o Passo 3. Vamos concentrar mais os nossos esforços aqui!

Passo 1. Aponte as Necessidades e Números de Necessidades

Todos estes Passos podem parecer bastante triviais no início. Mas realmente não são. Você pode pensar que o Passo 1 do processo de desenvolvimento de um Motor de Atirador em Primeira Pessoa pode ser resumido a isto:

Eu preciso carregar um Nível, a arma dos Jogadores, alguns Inimigos com IA. Feito, no passo 2.

Se fosse tão fácil. A melhor maneira de ir sobre o Passo 1 é percorrer todo o Jogo Clique por Clique, Ação por Ação desde Clicar no Ícone em sua Área de Trabalho, até atingir a Chave de Saída depois de rolar os Créditos. Faça uma Lista, uma grande Lista do que você precisa. Faça uma Lista do que você definitivamente não precisa.

Isso provavelmente vai acontecer assim:

Eu inicio o jogo e ele vai diretamente para o Menu Principal. O Menu irá usar uma Imagem Estática? Uma Cena de Corte? Como eu controlo o Menu Principal, Mouse? O Teclado? Que tipo de elementos de GUI preciso para o Menu Principal? Botões, Formulários, Barras de rolagem? E a Música?

E esses são apenas macro-conciderações. Entre o mais detalhado possível. Decidir que você precisa de Botões é bom, mas também é conciderar o que um botão pode fazer.

Eu quero que os botões tenham 4 Estados, Para cima, Para cima, Para baixo, Desactivado. Vou precisar de Som para os Botões? E os Efeitos Especiais? Eles são animados no estado de inatividade?

Se a sua Lista de Necessidades e Números de Necessidades contém apenas cerca de 10 itens no final do Menu Principal, você fez algo errado.

Neste estágio, o que você está fazendo é simular o Motor no seu cérebro e anotar o que precisa ser feito. O passo 1 vai ficar mais claro a cada iteração, não se preocupe em perder nada na primeira vez.

Passo 2. Organize as necessidades em Sistemas

Então, você tem suas listas de coisas que você precisa e não precisa. É hora de organizá-las. Obviamente, coisas relacionadas a GUI como Botões vão para algum tipo de Sistema GUI. Itens relacionados a renderização vão para o Sistema Gráfico / Motor.

Again, como no Passo 1, decidindo o que vai para onde será mais óbvio na sua segunda iteração, após o Passo 3. Para o primeiro passo, agrupe-os logicamente como no exemplo acima.

A melhor Referência sobre “o que vai onde” e “o que faz o quê” é sem dúvida a Book Game Engine Architecture de Jason Gregory.

Comece a agrupar a funcionalidade. Comece a pensar em formas de combiná-las. Você não precisa de Camera->rotateYaw(float yaw) e Camera->rotatePitch(float pitch) se você puder combiná-los em Camera->rotate(float yaw, float pitch). Mantenha-o simples. Demasiada funcionalidade (lembre-se, arrepio de funcionalidade) irá magoá-lo mais tarde.

Pense em que funcionalidade precisa de ser exposta publicamente e que funcionalidade só precisa de residir dentro do próprio Sistema. Por exemplo, o seu Renderizador precisa classificar todos os Sprites transparentes antes de desenhar. A função para classificar esses sprites, no entanto, não precisa ser exposta. Você sabe que precisa ordenar os sprites transparentes antes de desenhar, você não precisa de nenhum Sistema externo para lhe dizer isto.

Passo 3. A Arquitectura (Ou, o próprio Artigo)

Podemos muito bem ter começado o Artigo aqui. Esta é a parte interessante e importante.

Uma das Arquiteturas mais simples que seu motor pode ter é colocar cada Sistema em uma Classe e ter o Loop Principal do Jogo chamando suas subrotinas. Pode parecer algo como isto:

while(isRunning)
{
Input->readInput();
isRunning = GameLogic->doLogic();
Camera->update();
World->update();
GUI->update();
AI->update();
Audio->play();
Render->draw();
}

Parece perfeitamente razoável no início. Você tem todos os seus conceitos básicos cobertos, Input -> processando Input -> Output.

E de fato, isto será suficiente para um jogo simples. Mas será uma dor de cabeça para manter. A razão para isto deve ser óbvia: Dependências.

Cada Sistema deve comunicar com outros Sistemas de alguma forma. Nós não temos nenhum meio de fazer isso no nosso Loop de Jogo acima. Portanto o exemplo indica claramente, que cada Sistema deve ter alguma Referência dos outros Sistemas para poder fazer algo significativo. Nossa GUI e Lógica do Jogo devem saber algo sobre nosso Input. Nosso Renderizador precisa saber algo sobre nossa Lógica de Jogo para poder exibir qualquer coisa significativa.

Isso levará a essa maravilha arquitetônica:

Se cheirar como Spaghetti, é Spaghetti. Definitivamente não é o que nós queremos. Sim, é fácil e rápido de codificar. Sim, teremos resultados aceitáveis. Mas é possível mantê-lo, não é. Mude um pequeno pedaço de código em algum lugar e pode ter efeitos devastadores em todos os outros sistemas sem nós sabermos.

Outros, sempre haverá código que muitos Sistemas precisam de acesso. Tanto o GUI quanto o Renderizador precisam fazer chamadas de Draw ou pelo menos ter acesso a algum tipo de Interface para lidar com isso para nós. Sim, nós poderíamos simplesmente dar a cada Sistema o poder de chamar diretamente funções OpenGL / DirectX, mas acabaremos com muitas redundâncias.

Nós poderíamos resolver isso coletando todas as funções de desenho dentro do Sistema Renderizador e chamando aquelas do sistema GUI. Mas então o Sistema de Renderização terá funções específicas para a GUI. Estas não têm lugar no Renderizador e portanto são contrárias aos passos 1 e 2. Decisões, Decisões.

Assim a primeira coisa que devemos conciderar é dividir nosso Motor em camadas.

>

Lasagna Motora

Lasagna é melhor que Spaghetti. Pelo menos programação sábia. Seguindo o nosso Exemplo de Renderizador, o que queremos é chamar as funções OpenGL / DirectX sem chamá-las diretamente no Sistema. Isto cheira como um Wrapper. E na maior parte das vezes, é. Nós coletamos toda a funcionalidade de sorteio dentro de outra classe. Estas classes são ainda mais básicas do que os nossos Sistemas. Vamos chamar essas novas classes de Framework.

A idéia por trás disso é abstrair muito as chamadas de API de baixo nível e formá-las em algo feito sob medida para o nosso jogo. Nós não queremos definir o Vertex Buffer, definir o Index Buffer, definir as Texturas, habilitar isso, desabilitar isso apenas para fazer uma simples chamada de sorteio em nosso Sistema Renderizador. Vamos colocar todas essas coisas de baixo nível no nosso Framework. E eu vou chamar esta parte do Framework de “Sorteio”. Porquê? Bem, tudo o que ele faz é preparar tudo para desenhar e depois desenhá-lo. Não importa o que desenha, onde desenha, porque desenha. Isso é deixado para o Renderer System.

Isto pode parecer estranho, queremos velocidade no nosso motor, certo? Mais camadas de Abstracção = Menos Velocidade.

E você estaria certo, se fosse nos anos 90. Mas precisamos da capacidade de manutenção e podemos viver com a perda de velocidade quase imperceptível para a maioria das peças.

Como então o nosso Draw Framework deve ser desenhado? Simplificando, como nosso próprio pequeno API. SFML é um grande exemplo disso.

Coisas importantes a se ter em mente:

  • Conteúdo bem documentado. Que funções nós temos? Quando elas podem ser chamadas? Como são as chamadas?
  • Cutem-no de forma simples. Funções fáceis como drawMesh(Mesh* oMesh) ou loadShader(String sPath) irão fazê-lo feliz a longo prazo.
  • Keep it functional. Não seja muito específico. em vez de drawButtonSprite, tenha um drawSprite função e deixe que o chamador entregue o resto.

O que ganhamos? Alot:

  • Só precisamos configurar nosso Framework uma vez e podemos usá-lo em todos os Sistemas que precisamos (GUI, Renderer….)
  • Podemos mudar facilmente as API’s subjacentes se escolhermos, sem reescrever todos os Sistemas. Mudar de OpenGL para DirectX? Sem problema, apenas reescreva a classe do Framework.
  • Mantém o Código em nossos Sistemas limpo e apertado.
  • Salvando uma Interface bem documentada significa que uma pessoa pode trabalhar no Framework, enquanto uma pessoa trabalha na camada do Sistema.

Provavelmente acabaremos com algo assim:

Minha regra de ouro do que vai para o Framework é bastante simples. Se eu precisar chamar uma Biblioteca externa (OpenGL, OpenAL, SFML…) ou ter Estruturas de Dados / Algoritmos que todo Sistema precisa, eu deveria fazer isso no Framework.

Agora temos a nossa primeira camada de Lasanha feita. Mas nós ainda temos esta enorme bola de Spaghetti acima dela. Vamos resolver isso a seguir.

Mensagem

O Grande Problema, no entanto, permanece. Os nossos sistemas ainda estão todos interligados. Nós não queremos isso. Há uma multiplicidade de maneiras de lidar com este problema. Eventos, Mensagens, Classes Abstratas com Ponteiros de Função (How esoteric)…

Vamos ficar pelas Mensagens. Este é um conceito simples que ainda é muito popular na programação GUI. Também é bem adequado como um exemplo fácil para o nosso Motor.

Funciona como um Serviço Postal. A empresa A envia uma mensagem para a empresa B e solicita que algo seja feito. Estas empresas não precisam de ligação física. A empresa A simplesmente assume que a empresa B o fará em algum momento. Mas, por enquanto, a empresa A não se importa realmente quando ou como a empresa B o faz. Só precisa de ser feita. A empresa B pode até decidir redireccionar a mensagem para as empresas C e D e deixá-las tratar disso.

Podemos ir um passo além, a empresa A nem precisa de a enviar para alguém específico. A empresa A simplesmente envia a carta e qualquer um que se sinta responsável irá processá-la. Desta forma a empresa C e D pode processar diretamente o pedido.

Obviamente, as empresas igualam os nossos Sistemas. Vamos dar uma olhada em um exemplo simples:

  1. Esquema notifica o sistema de entrada que “A” foi pressionado
  2. Entrada traduz que a tecla “A” significa “Open Inventory” e envia uma mensagem contendo “Open Inventory”
  3. GUI lida com a Mensagem e abre a Janela Inventário
  4. Lógica do Jogo lida com a Mensagem e pausa o Jogo

Entrada nem se importa com o que está sendo feito com a sua Mensagem. A GUI não se importa que a Game Logic também processe a mesma Mensagem. Se todos eles estivessem acoplados, o Input precisaria chamar uma função no Sistema GUI e uma função na Lógica do Jogo. Mas já não precisa de o fazer. Nós fomos capazes de desacoplar com sucesso usando Messages.

Como é que uma Mensagem se parece? Ela deve ter pelo menos algum Tipo. Por exemplo, abrir o inventário poderia ser algum enumero chamado OPEN_INVENTORY. Isto é suficiente para Mensagens simples como esta. Mensagens mais avançadas que precisam incluir dados vão precisar de alguma forma para armazenar esses dados. Há uma infinidade de maneiras de se conseguir isso. A mais fácil de implementar é usando uma estrutura de mapa simples.

Mas como enviamos Mensagens? Através de um Message Bus, claro!

Não é lindo? Acabou-se o Spaghetti, apenas a boa e simples Lasanha. Eu deliberadamente coloquei a nossa lógica de jogo do outro lado do autocarro de mensagens. Como você pode ver, ele não tem nenhuma conexão com a camada Framework. Isto é importante para evitar qualquer tentação de “chamar a isso apenas uma função”. Confie em mim, você vai querer mais cedo ou mais tarde, mas isso iria quebrar o nosso design. Nós temos Sistemas suficientes lidando com o Framework, não há necessidade de fazer isso em nossa Lógica de Jogo.

O Message Bus é uma simples Classe com Referências para cada Sistema. Se tiver uma Mensagem na fila, o Message Bus a coloca em cada Sistema através de uma simples handleMessage(Msg msg) call. Em retorno, cada Sistema tem uma referência para o Message Bus para postar Mensagens. Isto pode obviamente ser armazenado internamente ou passado como um argumento de função.

Todos os nossos Sistemas devem portanto herdar ou ser da seguinte forma:

class System
{
public:
void handleMessage(Msg *msg);
{
switch(msg->type)
{
//// Example
//case Msg::OPEN_INVENTORY:
// break;
}
}

private:

MessageBus *msgBus;
//// Usage: msgBus->postMessage(msg);
}

(Sim, Sim, Ponteiros brutos…)

Suddenly, nosso Loop do Jogo muda para simplesmente deixar o Barramento de Mensagens enviar ao redor das Mensagens. Ainda precisaremos atualizar periodicamente cada Sistema através de alguma forma de update() chamada. Mas a comunicação será tratada de forma diferente.

No entanto, como com a nossa Frameworks, o uso de Mensagens cria uma sobrecarga. Isto irá abrandar um pouco o motor, não nos iludamos. Mas nós não nos importamos! Queremos um Design limpo e simples. Uma arquitetura limpa e simples!

E a parte mais legal? Recebemos coisas incríveis de graça!

A Consola

Cada mensagem é praticamente uma chamada de função. E cada Mensagem é enviada para praticamente todo o lado! E se tivermos um Sistema que simplesmente imprime cada Mensagem que é enviada para alguma janela de Saída? E se este Sistema também puder enviar Mensagens que digitamos nessa janela?

Sim, acabamos de dar à luz uma Console. E tudo o que nos foi preciso foram algumas linhas de código. A minha mente ficou desfeita quando vi isto pela primeira vez em acção. Nem sequer está ligado a nada, apenas existe.

Uma consola é obviamente muito útil no desenvolvimento do jogo e podemos simplesmente retirá-la no Release, se não quisermos que o Jogador tenha esse tipo de acesso.

Em-Game Cinematics, Replays & Debugging

E se nós falsificarmos Mensagens? E se criarmos um novo sistema que simplesmente envie Mensagens a uma determinada hora? Imagine ele enviando algo como MOVE_CAMERA, seguido por ROTATE_OBJECT.

E Voila, temos In-Game Cinematics.

E se simplesmente gravarmos as Mensagens de Entrada que foram enviadas durante o Jogo e as guardarmos num ficheiro?

E Voila, temos Replays.

E se nós simplesmente gravarmos tudo o que o jogador faz, e quando o jogo trava, eles mandam esses arquivos de dados para nós?

E Voila, nós temos uma cópia exata das ações dos jogadores que levam ao travamento.

Multi-Threading

Multi-Threading? Sim, Multi-Threading. Nós desacoplamos todos os nossos sistemas. Isto significa que eles podem processar suas mensagens quando quiserem, como quiserem e o mais importante, onde quiserem. Podemos ter nosso Message Bus a decidir em que thread cada Sistema deve processar uma Mensagem -> Multi-Threading

Frame Rate Fixing

We have too many Messages to process this Frame? Não há problema, vamos mantê-las na Fila de Bus de Mensagens e enviá-las no próximo Frame. Isto nos dará a oportunidade de garantir que o nosso Jogo funcione a 60 FPS. Os jogadores não vão notar que a IA vai demorar mais alguns Frames para “pensar”. No entanto, eles vão notar que a taxa de frames cai.

Mensagens são legais.

É importante que documentemos meticulosamente cada mensagem e os seus parâmetros. Trate-a como uma API. Se você fizer isso corretamente, cada desenvolvedor pode trabalhar em diferentes Sistemas sem quebrar nada. Mesmo que um Sistema esteja offline ou em Construção, o Jogo ainda será executado e pode ser testado. Sem Sistema de Áudio? Tudo bem, ainda temos Visuals. Sem Renderizador, tudo bem, podemos usar o Console…

Mas as Mensagens não são perfeitas. Infelizmente.

Algumas vezes, nós queremos saber o resultado de uma Mensagem. Às vezes precisamos que elas sejam processadas imediatamente. Precisamos de encontrar opções viáveis. Uma solução para isso é ter um Speedway. Além de uma simples postMessage função, podemos implementar uma postImmediateMessage função que é processada de imediato. O manuseio de mensagens de retorno é muito mais fácil. Estas são enviadas para a nossa handleMessage função, mais cedo ou mais tarde. Nós só precisamos lembrar disso ao postar uma Mensagem.

Mensagens Imediatas obviamente quebram Multi-Threading e Frame Rate Fixing se feito em excesso. Assim, é vital restringir-se a limitar seu uso.

Mas o maior problema com este Sistema é a latência. Não é a arquitetura mais rápida. Se você está trabalhando em um Atirador em Primeira Pessoa com tempos de resposta parecidos com os de um twitch, isto pode ser um quebra de contrato.

Voltar para Projetar nossa Arquitetura

Decidimos usar Sistemas e um Message Bus. Sabemos exactamente como queremos estruturar o nosso Motor.

É altura para o Passo 4 do nosso processo de Design. Iteração. Algumas funções podem não caber em nenhum Sistema, temos que encontrar uma solução. Algumas funções precisam ser chamadas extensivamente e entupiriam o barramento de mensagens, precisamos encontrar uma solução.

Isso leva tempo. Mas vale a pena a longo prazo.

Finalmente é hora de codificar!

Passo 4. Onde começar a Codificar?

Antes de começar a codificar, leia os Padrões de Programação de Jogos de Livro/Artigos de Robert Nystrom.

>

Outra coisa, eu esbocei um pequeno roteiro que você poderia seguir. Não é de longe o melhor caminho, mas é produtivo.

  1. Se você estiver indo com um Message Bus tipo Motor, codifique primeiro o Console e o Message Bus. Uma vez implementados, você pode falsificar a existência de qualquer Sistema que ainda não tenha sido codificado. Você terá controle constante sobre todo o motor em cada estágio de desenvolvimento.
  2. Concider passando para a próxima GUI, bem como a funcionalidade necessária de Draw dentro do Framework. Uma sólida GUI emparelhada com o Console permitirá que você falsifique todos os outros Sistemas ainda mais facilmente. O teste será uma brisa.
  3. Próximo deve ser o Framework, pelo menos a sua interface. A funcionalidade pode seguir mais tarde.
  4. Finalmente, passe para os outros Sistemas, incluindo Gameplay.

Vai notar, na verdade renderizar qualquer coisa relacionada com Gameplay pode ser a última coisa que vai fazer. E isso é uma coisa boa! Vai parecer muito mais gratificante e manter você motivado para terminar os toques finais do seu Motor.

Seu Game Designer pode atirar em você durante este processo, no entanto. Testar a jogabilidade através dos comandos do Console é tão divertido quanto jogar Counter Strike via IRC.

Conclusion

Tire seu tempo para encontrar uma Arquitetura sólida e fique com ela! Esse é o conselho que eu espero que você tire deste artigo. Se você fizer isso, você será capaz de construir um motor perfeitamente fino e de fácil manutenção no final do dia. Ou século.

Pessoalmente, eu gosto mais de escrever Motores do que de fazer todas essas coisas de Gameplay. Se você tiver alguma dúvida, sinta-se a vontade para me contatar via Twitter @Spellwrath. No momento estou terminando outra engine usando os métodos que descrevi neste artigo.

Você pode encontrar a Parte 2 aqui.

Similar Posts

Deixe uma resposta

O seu endereço de email não será publicado.