Topo

Histórico

Por trás do jogo: o que faz um programador de engine?

Thais Weiller

17/03/2020 16h00

Depois da apresentação das áreas de desenvolvimento de jogos, eu convidei diferentes profissionais para falarem da sua área diretamente aqui no Blog. Quem assume a tarefa hoje é Felipe Lira, um programador gráfico especializado no desenvolvimento de Engines Gráficas (motores gráficos). Ele atualmente trabalha na Unity como líder do desenvolvimento do Universal Render Pipeline. Previamente ele trabalhou na Tectoy Mobile desenvolvendo jogos e engine para o Zeebo, na Samsung (SIDIA) na produção de jogos para realidade virtual e dentre outras empresas de desenvolvimento de jogos.

O que é uma engine gráfica?

Pra falar um pouco sobre como é o desenvolvimento de engine, primeiro você precisa saber o que é uma engine gráfica.

Você pode imaginar a engine gráfica como uma caixa preta. Como entrada ela recebe dados de objetos da cena do jogo (câmeras, luzes, modelos, materiais, shaders, texturas), e é responsável por produzir imagens na tela. Uma imagem produzida pela engine gráfica é também chamada de frame.

Engine Gráfica Unity

Um jogo idealmente precisa gerar pelo menos 60 frames por segundo para ter uma boa experiência e jogabilidade. Algumas plataformas de realidade virtual, como o Oculus Quest, precisam gerar taxas de frames ainda mais altas para uma boa experiência. Isso significa que, no mínimo, para se ter uma boa experiência é preciso gerar um frame a cada 16.6ms.

Para ser eficiente, a engine gráfica divide o trabalho de gerar frames em vários passos, que frequentemente podem ser paralelizados. A paralelização pode acontecer tanto na distribuição do trabalho entre CPU e GPU, quanto também em paralelização na própria GPU(Async Compute). O conjunto de passos para gerar uma determinada imagem é chamado de render pipeline.

Um render pipeline simples possui um passo de culling, alguns passos de renderização (render passes) e pós-processamento de imagem. Adrian Courrèges descreve o render pipeline do DOOM 2016 nesse blog

A Unity possui ferramentas no editor que ajudam a visualizar e entender esses passos para os seus render pipelines. Usando o Frame Debugger é possível ver os passos de renderização do BoatAttack.

Um frame do demo BoatAttack.

O demo renderiza uma camera para reflexão planar, shadow map, foam map, desenha objetos opacos de frente pra traz, passo de caustics, skybox, desenha objectos transparentes de trás pra frente e por fim aplica um série de efeitos de pós-processamento.

Você pode encontrar mais detalhes técnicos sobre o Boato Attack neste blog. O projeto está disponível publicamente neste Github.

Agora que você sabe o que é uma engine gráfica. Vou contar um pouco de como é o processo de desenvolver uma.

Como é desenvolver uma engine gráfica

O desenvolvimento e manutenção de uma engine é um ciclo que gira em torno das seguintes perguntas:

  1. Que feature devemos implementar?
  2. Como implementar essa feature?
  3. Como testar a feature?

Ao introduzir uma nova feature na engine, devemos verificar que essa feature atinge as expectativas de quem a solicitou, que ela funciona como esperado nas plataformas que a engine suporta e que ela não introduz nenhum bug ou regressão de performance na engine.

Existe uma grande diferença no processo de desenvolvimento se você está fazendo uma engine especializada para um tipo de jogo ou plataforma ou se você está desenvolvendo uma engine multiplataforma que deve funcionar para uma grande variedade de tipos de jogo e uma quantidade enorme de plataformas, como é o caso da Unity.

Desenvolvendo uma engine gráfica especializada

Quando trabalhei na Tectoy Mobile, fiz parte do time de desenvolvimento da engine gráfica para os jogos Zeebo Extreme. Em alguns estúdios existe a separação do time de desenvolvimento do jogo e do time de engine. Na Tectoy não existia essa separação clara, o time de engine trabalhava junto com o desenvolvimento. A engine foi feita especificamente para aqueles jogos.

Zeebo, console brasileiro lançado em 2009.

Então pra engine do Zeebo, como é o processo de acordo com as 3 perguntas acima?

1) Que funcionalidade devemos feature?
A maioria das funcionalidades são decididas na pré-produção do jogo. O produtor junto com os líderes de projeto definem o backlog dessas features. Em muitos casos, funcionalidades adicionais ou modificações são requisitadas durante a produção do jogo porém, frequentemente, em menor escala de impacto.

2) Como implementar essa feature?
Os jogos Zeebo Extreme tem como plataforma alvo apenas o Zeebo. Sabendo detalhes da arquitetura do Zeebo, e com ajuda de alguns benchmarks de performance podemos tomar a decisão de que estratégia ou nível qualidade é aceitável para essa feature.

3) Como testar a feature?
Na engine do Zeebo tínhamos apenas testes manuais. O desenvolvedor testava manualmente a funcionalidade durante o desenvolvendo. O time de QA validava a funcionalidade. Testes de regressão de qualidade ou performance eram apenas feitos em releases internos ou externos do jogo pelo time de QA.

Ok. Então vamos dar um exemplo de uma funcionalidade na engine do Zeebo. Sabíamos que Zeebo tinha um poder de processamento gráfico bem pequeno. O que significa que precisávamos ter um sistema de culling agressivo, i.e, remover o máximo possível de objetos nao visiveis pela camera, incluindo objetos ocludidos por outros objetos. (occlusion culling). Logo, 1) que feature devemos feature?, Occlusion Culling.

Como nós sabíamos que era uma engine para jogos de corrida e fazer um sistema genérico de occlusion culling em CPU era muito custoso pro Zeebo, nós implementamos um sistema de oclusão baseado por waypoints. Nesse sistema, o artista dividia as pistas em vários segmentos (waypoints). Em uma ferramenta offline, para cada waypoint, nós pré computamos uma lista visível de triângulos. O artista colocava planos de oclusão no modelo da pista e esses planos eram apenas usados para computar essa lista de triângulos. Essa informação de triângulos era guardada em um formato específico de modelo feito pra engine e o jogo apenas decidir em qual waypoint carregar quais segmento da pista. Nesse caso a resposta para 2) Como implementar essa feature?, será implementado com um sistema de waypoints e pré processamento offline.

Finalmente, 3) Como testar a feature?, o desenvolvedor testa o sistema de oclusão em uma pista, verifica que funciona. O time de QA testa nas demais pistas. Geramos uma build do jogo e verificamos que a performance usando o sistema de oclusão é melhor que não usá-lo. A feature é considerada entregue. (definition of done)

Uma outra facilidade de estar desenvolvendo uma engine para um time específico é a manutenção da engine é bem mais simples e rápida. Se precisarmos atualizar o formato do arquivo de oclusão ou alguma API pública da engine, apenas informamos o time de desenvolvimento para atualizar a API e os arquivos na próxima versão. Simples e rápido.

Desenvolvendo uma engine gráfica em multiplataforma

Desenvolver uma engine gráfica multiplataforma como a Unity é um processo bem mais complexo. A Unity suporta uma variedade diferente de jogos e roda em diversas plataformas.

Uma solução que funciona bem para um jogo ou plataforma pode não necessariamente ser uma boa opção em outras plataformas. A Unity ainda suporta plataformas antigas que usam OpenGL ES 2.0. Isso é um fator bastante limitante quanto a escolhas em como implementar funcionalidades novas, afinal essas funcionalidades precisam funcionar em todas as plataformas.

Então pra engine do Unity, vou explicar como é o processo de desenvolvimento de acordo com as três perguntas acima para o Universal Render Pipeline.

1) Que funcionalidade devemos feature?
O time de desenvolvimento cria um backlog de features desejáveis. Coletamos feedback do time de PM (Product Management), do time que faz interface com clientes com suporte premium (Enterprise Support), e feedback dos usuários no fórum do Universal Render Pipeline. Com todas essas informações, criamos um backlog de features público, contendo apenas features planejadas para um curto ou médio prazo. Os usuários podem ainda votar nesse backlog, o que pode mudar a priorização dessas features. Baseado nesse em todo o feedback recebido, nós priorizamos as features no backlog.

Além disso, a Unity contém Analytics que pode ser opcionalmente ligado ou desligado pelos usuários. O Analytics nos informa quais features são mais ou menos usadas, e podemos usar esse tipo de informação para priorizar suporte ou deixar de dar suporte para algumas features ou plataformas.

2) Como implementar essa feature?
Cada feature na engine possui um custo de manutenção. Como suportamos uma quantidade grande de usuários e plataformas esse custo é alto. Logo, introduzir features muito específicas para um tipo de jogo ou plataforma não é recomendado pois isso cria uma fragmentação de features e aumenta o custo de manutenção. Ao pensar em como implementar uma feature, pensamos em um solução que seja adequada para a maioria dos jogos e plataformas. No Universal Render Pipeline, nós priorizamos o design de features que escalam em termos de performance para plataformas mobile e Nintendo Switch. Isso significa que um maior foco na estratégia de desenvolvimento e testes de performance são feitas nessas plataformas. Na grande maioria dos casos as estratégias adotadas para mobile escalam bem para desktop e consoles. Já o High Definition Render Pipeline (HDRP), contém otimizações específicas que só são suportadas em consoles e última geração de placas gráficas desktop. Isso é possível porque o HDRP só suporta um grupo de plataformas de alta performance.

Em alguns casos, uma solução específica para uma determinada feature seria mais adequada. Para isso, além de desenvolver as features que escalam para todas os tipos de jogos e plataformas, nós também desenvolvemos pontos de customização na engine que permitem usuários mais avançados extrair o máximo da engine usando soluções específicas.

3) Como testar a feature?
Na Unity nós usamos demos ou pequenas produções para validar novas features e também para usar esses projetos como testes manuais para evitar regressões de qualidade ou performance. Isso é chamado de Production Driven Development. O Boat Attack é um dos projectos que usamos para validar novas features e fazer release QA no Universal Render Pipeline.

Boat Attack é usado para validar múltiplas features gráficas.

Além de usar pequenas produções, nós escrevemos testes gráficos para validarem que a feature funciona. Um teste gráfico contém uma imagem de referência que diz o resultado esperado da renderização de uma feature e o framework de testes compara a imagem gerada pelo render pipeline com a imagem referência pixel a pixel. Cada nova feature ou bugfix deve adicionar um teste. Esses testes são rodados automaticamente por máquinas de testes (Continuous Integration) e o código só pode ser introduzido na engine quando o grupo de todos os testes automatizados passarem. Por exemplo, esse é um pull request (PR) para corrigir um bug no Universal Render Pipeline. Como podemos ver, o PR contém uma seção de testes manuais e automáticos necessários para validar que o bug foi corrigido efetivamente. Esse tipo de paradigma orientado a testes é chamado de TDD (Test Driven Development) e é bastante eficaz. Testes não só ajudam a evitar regressões como também ajudam a validar a feature. Muitos problemas de design são detectados ainda na fase de escrita de testes automatizados.

No Universal Render Pipeline, ainda não temos testes automatizados de performance. O time está trabalhando em um framework de performance de testes gráficos utilizando o framework de testes para performance. A maioria dos testes de performance são feitos manualmente antes do release de uma nova versão. Estamos adicionando agora testes automatizados para Android, o que vai facilitar bastante o processo de desenvolvimento para essa plataforma.

Uma outra dificuldade é quanto a atualização da engine. Nós não podemos simplesmente atualizar uma API pública ou mudar a representação de dados de objetos. Isso causaria uma quantidade enorme de jogos terem problemas de compilação ou simplesmente não funcionar como esperado até que o desenvolvedor atualize manualmente o projecto para  próxima versão. É recomendado que sempre haja um upgrade automático de API e dados. A Unity suporta vários mecanismos que nos permite atualizar automaticamente scripts e objetos no projeto. Quando isso acontece, no próximo update o usuário recebe uma caixa de diálogo informando que um update do projeto acontecerá.

Na maioria dos casos escrever o upgrade automático e validar que ele funcionar é mais trabalhoso do que implementar a nova feature ou corrigir o bug. O famoso "o molho sai mais caro que o peixe".

Para casos que um upgrade automático não é possível, escrevemos um documento chamado "Upgrade Guide" para cada nova versão. Ao ler esse documento os usuários sabem que processos manuais devem aplicar em seus projetos para usar a nova versão.

Sobre a autora

Thais Weiller é mestre pela ECA-USP pesquisando game design, com uma dissertação que virou um livro e um blog. Ela trabalhou em games como “Oniken”, “Odallus”, “Finding Monsters”, “Rainy Day” entre outros, e também fundou, junto com Danilo Dias, a desenvolvedora JoyMasher. Atualmente, Thais dá aulas de design de jogos na PUC do Paraná.

Sobre o Blog

Quais os mitos e fantasias que influenciam nosso comportamento e afetam nossa paixão pelos games? Neste espaço, Thais vai trazer a perspectiva dos pesquisadores e desenvolvedores de jogos para nos ajudar a entender os games de uma maneira diferente.