5 pontos por GN⁺ 2024-08-05 | 1 comentários | Compartilhar no WhatsApp
  • high_impact, que resgata a estrutura do motor JavaScript Impact de 2010, é um motor em C para jogos de ação 2D, com suporte a Windows, Mac, Linux e WASM para a Web
  • O Impact foi criado em meio ao movimento de abandono do Flash no iOS para mostrar que também era possível fazer jogos web com Canvas2D; após vender mais de 3.000 licenças por $99, acabou sendo lançado como open source gratuito
  • O novo motor é um pequeno framework que reúne tilemaps, entidades, física e colisão, animação de sprites, texto e som, usando SDL ou Sokol como backend
  • A implementação mantém a simplicidade com armazenamento de entidades de tamanho fixo, assets em QOI/QOA, uma única área de memória hunk, renderizadores OpenGL e por software, e o editor de fases Weltmeister baseado em JavaScript
  • Foi possível portar e executar Biolab Disaster e Drop de forma próxima ao código-fonte original em JS, e o projeto pode se expandir para vários sistemas com extensões de plataforma e renderizador

Visão geral do high_impact

  • high_impact é um pequeno motor de jogos para ação 2D
  • Foi escrito em C e compilado para Windows, Mac, Linux e WASM para a Web
  • É inspirado no motor de jogos em JavaScript Impact, de 2010, e o nome faz referência a uma época em que C era considerado uma linguagem de alto nível
  • Foi lançado sob a licença MIT, e o código está no GitHub

O contexto em que o Impact foi criado

  • Em abril de 2010, Steve Jobs publicou a carta aberta “Thoughts on Flash”, afirmando que o iOS não teria suporte a Flash
  • Na época, o Flash era o centro da cultura de jogos e animações na web baseada em plugins de navegador, e sites como Newgrounds e Kongregate dependiam fortemente de conteúdo em Flash
  • O suporte a Flash no Android era problemático, e considera-se que a Adobe também não fez esforços para corrigir as desvantagens no mobile
  • Havia a percepção de que “sem Flash não existem jogos no navegador”, mas a API Canvas2D já permitia desenhar imagens e formas em <canvas>
  • O Canvas2D foi criado pela Apple/Safari para renderização de widgets no desktop, depois recebeu suporte de Google e Mozilla, enquanto o Internet Explorer da Microsoft ficou para trás
  • Foi nesse contexto que Biolab Disaster foi criado, junto com o motor de jogo e o editor de fases necessários para ele

Vendas do Impact e casos de uso

  • O Impact passou por limpeza de código e documentação e foi vendido por $99; apesar da reação negativa à decisão de cobrar, vendeu mais de 3.000 licenças
  • Vários jogos web foram feitos com o Impact, e ele também foi usado em títulos comerciais multiplataforma
  • No fim de sua vida útil, o Impact foi lançado como open source gratuito
  • O high_impact é um projeto que recria o Impact do zero, mas em C em vez de JavaScript

Por que C?

  • C é vista como uma linguagem simples, mas profunda, parecida com jogos no sentido de ser “fácil de aprender e difícil de dominar”
  • O interesse por C voltou a crescer após vários projetos
    • Port do decodificador MPEG1 em JavaScript para a biblioteca single-header pl_mpeg
    • Implementação de VR em Quake para Oculus Rift
    • Criação do formato de imagem QOI e do formato de áudio QOA
    • Reescrita de wipEout
  • O Impact originalmente não era comparável em escala com motores como Godot, Unreal ou Unity, mas ainda assim serviu como base sólida para vários jogos
  • Reescrever o Impact em C começou como um exercício divertido

Estrutura do motor e assets

  • O high_impact foi implementado da forma mais simples possível, com a meta de usar o mínimo de código viável
  • As funções básicas são as mesmas do motor original em JavaScript
    • Carregamento de tilemaps
    • Criação, atualização e desenho de entidades, os objetos do jogo
    • Física e colisão entre entidades
    • Colisão com o mapa de colisão
    • Animação com sprite sheets
    • Exibição de texto
    • Reprodução de efeitos sonoros e música
  • Ele se parece mais com um framework do que com uma biblioteca, e a lógica do jogo é escrita dentro do próprio framework
  • Na base existe um backend de platform, hoje compilado com SDL ou Sokol
  • O código do jogo fica em uma ou mais “scene”, e uma scene é uma struct com ponteiros de função
    • Após chamar engine_set_scene(&scene_game), o motor define a nova scene
    • scene_game.init() é chamado uma vez
    • scene_game.update() e scene_game.draw() são chamados a cada frame
  • O tilemap e as entidades iniciais podem ser carregados de arquivos .json ou criados dinamicamente
  • O motivo para escolher JSON como formato de fase foi a compatibilidade retroativa com o Impact original
  • O high_impact usa QOI para imagens e QOA para som e música
    • O Makefile do jogo de demonstração converte automaticamente PNG para QOI e WAV para QOA
    • Não é necessário incluir bibliotecas separadas de decodificação de imagem e som
  • Há possibilidade de suporte a outros formatos de asset no futuro, mas a simplicidade de QOI/QOA combina bem com a direção do projeto

Sistema de entidades

  • Todas as entidades compartilham a mesma entity_t struct, que contém os atributos necessários ao high_impact, como posição, velocidade e tamanho
  • Como todas as entidades têm o mesmo tamanho em bytes, armazenamento e gerenciamento ficam mais simples
  • Para mover uma entidade, basta definir velocidade ou aceleração; o restante é tratado pelo motor
  • Por meio de macros, é possível adicionar atributos específicos do jogo à struct base de entidade
    • Biolab Disaster usa uma union com structs por tipo de entidade
    • Drop não precisa definir atributos adicionais
  • Cada tipo de entidade precisa ter um entity_vtab_t com ponteiros de função
    • update é chamado a cada frame
    • touch é chamado quando há sobreposição com outra entidade que atenda às condições
    • Todos os itens são opcionais
  • O armazenamento de entidades tem tamanho fixo
    • O padrão é 1.024 entidades ativas
    • Pode ser configurado pela definição ENTITIES_MAX
    • O motor lida facilmente com até 64k entidades
  • Para manter referências de entidade por mais de um frame, usa-se entity_ref_t
    • entity_ref_t é uma struct com uint16_t id e index
    • Ela pode ser convertida novamente em ponteiro com entity_by_ref()
    • Isso permite distinguir quando outra entidade ocupou o mesmo endereço de armazenamento
    • Por causa do índice uint16_t, o limite máximo de entidades ativas é 64k
  • Implementar em C estruturas como OOP simples, classes e herança simples é um pouco desajeitado, mas o high_impact tenta tornar isso o mais confortável possível
  • A abordagem “ingênua” de OOP, mantendo a lógica de entidade em um só lugar por tipo, foi fácil de entender e funcionou bem nos jogos feitos até agora

Detecção e resposta de colisão

  • O tratamento simples de colisão apenas verifica se é possível mover até a nova posição e, se não for, para ali, mas isso pode gerar comportamentos estranhos com objetos rápidos
  • Num jogo de plataforma 2D, se o jogador estiver 16 px acima do chão e o próximo movimento o fizer atravessar o solo, ele pode parecer parar no ar e só cair no frame seguinte, dando a impressão de um pouso suave
  • O high_impact faz o rastreamento da caixa da entidade contra o tilemap para calcular o ponto exato da colisão
  • Esse método é mais complexo do que uma simples checagem de sim/não, mas produz resultados melhores e também lida com tiles inclinados
  • Após bater em um tile, pode ser necessário um segundo rastreamento com a velocidade restante
    • Por exemplo, ao tocar o chão em diagonal, vel.y vira 0, mas vel.x é mantido para permitir que a entidade deslize pelo piso
  • As colisões entre entidades são tratadas separadamente
    • Partículas podem colidir com o tilemap, mas não com outras entidades
    • Plataformas móveis podem colidir com outras entidades, mas não devem se mover como resposta à colisão
  • A detecção de colisão broad phase ordena as entidades com base em pos.x
    • Como no frame anterior quase tudo já estava ordenado, o custo do insertion sort é baixo
    • Depois da ordenação, o sistema varre da esquerda para a direita e verifica apenas entidades entre pos.x e pos.x + size.x
  • Esse método de sweep and prune é rápido, desde que muitas entidades não se sobreponham em posições x parecidas
  • O pior caso acontece quando muitas entidades se concentram na mesma posição x, como numa torre de caixas empilhadas
  • Em casos como jogos de tiro vertical, em que o outro eixo faz mais sentido, é possível trocar o eixo de sweep com #define ENTITY_SWEEP_AXIS y

Renderização

  • O high_impact atualmente inclui um renderizador OpenGL e um renderizador por software ainda incompleto
  • Toda a renderização passa por uma API muito fina, e as chamadas reais de desenho acontecem em uma única função, o que simplifica relativamente a implementação de outros backends
  • As funções principais que um backend extra de renderização precisa oferecer são inicialização, limpeza, definição do tamanho da tela, preparação e encerramento do frame e desenho de quads
  • Para lidar com texturas, também são necessárias funções de marcação, reset e criação
  • O conjunto de recursos é simples: só é possível desenhar quads e não há efeitos com shader, mas isso é suficiente para o objetivo do motor
  • O renderizador por software tem 140 linhas de código e só suporta quads alinhados aos eixos
  • O renderizador OpenGL tenta colocar toda a renderização de um frame em uma única draw call do OpenGL
    • Todos os quads são reunidos em um buffer grande e enviados de uma vez com glDrawElements()
    • Todas as texturas são combinadas em um único texture atlas para evitar rebinding de textura
  • Texture atlas é uma técnica antiga e tem desvantagens, mas ainda é usada porque bindless texture não é suportado em todos os lugares
  • O high_impact suporta apenas um único texture atlas, mas o tamanho pode ser configurado com #define
    • GPUs mobile normalmente suportam texturas de 8k×8k
    • GPUs desktop modernas aparentemente chegam a 32k×32k
    • Biolab Disaster e Drop usam um atlas de 512×512

Som

  • A saída de áudio é tratada por SDL2 ou Sokol, enquanto o motor cuida do carregamento, da decodificação e da mixagem de vários sons
  • O sistema de som é dividido entre sound_source_t, que contém as amostras, e sound_t, que representa um som em reprodução
  • O sistema se baseia no que foi criado durante a reescrita de wipEout, e pode descompactar QOA sob demanda
  • Tudo é alocado estaticamente
    • O número de sources carregáveis é fixo
    • O número de sounds que podem tocar ao mesmo tempo é fixo
    • Sounds que terminam de tocar são descartados automaticamente e reutilizados
  • É possível alterar volume, panorama estéreo e pitch dos sons
  • Se o pitch for definido como negativo, o som toca ao contrário
  • O resampling necessário para pitch variável usa um método de baixa qualidade com interpolação do vizinho mais próximo

Gerenciamento de memória

  • No high_impact, assume-se que, se o jogo não tiver assets criados pelo usuário, é possível saber exatamente quanta memória será necessária
  • O motor aloca estaticamente um único array de bytes chamado “hunk”, e essa é toda a memória usada pelo high_impact
  • O tamanho do hunk pode ser definido com #define ALLOC_SIZE
  • Dentro do hunk, a memória é alocada de duas formas
    • Um bump allocator que cresce a partir do início, ou arena, armazena assets do jogo, entidades e dados da scene atual
    • Um alocador temporário que cresce a partir do fim, como malloc() e free(), é usado para dados transitórios, como após descompactar uma imagem e antes de enviá-la para a GPU
  • O bump allocator tem vários “high water marks” e volta automaticamente para um ponto específico
  • A memória alocada por bump não precisa de free() explícito
  • As durações conceituais se dividem em game, scene e frame
    • O que é alocado antes da primeira scene ser definida só é liberado no encerramento do programa
    • O que é alocado durante scene.load() é liberado quando a scene termina
    • O que é alocado durante a execução da scene é liberado no fim do frame
  • O load() por tipo de entidade é chamado na etapa 1 porque não se sabe antecipadamente quais entidades serão usadas na scene
  • Contextos adicionais de alocação podem ser encapsulados com alloc_pool(), que internamente é um atalho para bump_mark() e bump_reset(mark)

Editor de fases Weltmeister

  • O Impact original tinha o editor de fases Weltmeister, e ele também está incluído no high_impact
  • Ele continua escrito em JavaScript e reaproveita bastante do código original, mas foi atualizado para recursos modernos do navegador
  • O Weltmeister funciona de forma totalmente independente
    • É possível dar duplo clique em weltmeister.html e começar a criar fases
    • Antes era necessária uma API backend em PHP ou NodeJS para carregar e salvar arquivos
    • Agora ele pode pedir permissão de acesso a uma pasta específica via FileSystemAPI
  • Safari e Firefox ainda não oferecem suporte completo, em especial para showDirectoryPicker(), então é preciso usar um navegador baseado em Chrome
  • O Weltmeister lê arquivos-fonte em C e coleta os tipos de entidade
  • O high_impact fornece macros que o editor entende, mas que não fazem nada no código C
    • EDITOR_SIZE(X, Y): tamanho no editor, padrão (8, 8)
    • EDITOR_RESIZE(RESIZE): se o tamanho pode ser ajustado no editor
    • EDITOR_COLOR(R, G, B): cor da caixa no editor, padrão (128, 255, 128)
    • EDITOR_IGNORE(IGNORE): se pode ser criado no editor

Jogos de demonstração

  • Para verificar se o high_impact realmente funciona como motor de jogo, dois jogos originais do Impact foram portados para C
  • O trabalho de port foi mais próximo de uma “transliteração” do código JS existente, reaproveitando os assets originais
  • O fato de ter sido pouco desafiador pode ser visto como evidência de que o high_impact funcionou como pretendido
  • Biolab Disaster

  • Drop

Expansibilidade

  • O high_impact segue uma estrutura em que o código específico do jogo é escrito de forma aditiva, como em motores tradicionais
  • Não é necessário mexer no código-fonte do motor, mas ele foi feito para ser simples o bastante a ponto de você poder alterá-lo diretamente, se precisar
  • Plataforma e renderizador foram projetados para poderem ser expandidos sem mudar o restante do código
  • Se houver interesse, são bem-vindos pull requests com renderizadores Vulkan, DirectX, Metal e suporte a backends de plataforma como PSX, N64 e Dreamcast
  • Por ter sido escrito em C, a ideia é que ele possa rodar em qualquer lugar

1 comentários

 
GN⁺ 2024-08-05
Comentários do Hacker News
  • Uma boa parte do trabalho de programação em que mais aprendi foi graças ao Impact
    O Impact estava realmente muito à frente do seu tempo, e tenho orgulho de ter sido uma das 3.000 pessoas com licença. Foi uma das melhores compras que já fiz, e o único jogo que realmente terminei de ponta a ponta também foi feito com Impact
    Eu gostava do fato de a licença incluir o código-fonte, e eu mesmo modifiquei o engine e o editor conforme as minhas necessidades. Por influência disso, passei alguns anos criando meu próprio engine de jogos em JS, mas acabei adiando terminar jogos de fato; ainda assim, aprendi muito no processo e fiz vários jogos para game jams
    Também fui inspirado pelo Ejecta, o suporte nativo para iOS do Impact, mas me frustrava ele não rodar no Android naquela época, então criei bindings de JVM para o V8 e implementei parte do WebGL para rodar meu engine no Android sem WebView. Inesperadamente, o repositório dos bindings V8 que publiquei acabou sendo usado em software comercial: https://github.com/namuol/jv8
    Inspirado pelo modelo de negócios do Impact, cheguei até a tentar uma startup bootstrap vendendo acesso a repositórios privados no GitHub, mas essa história ficaria longa. Enfim, ver o Impact sendo atualizado para a web “moderna” com um port para C me deixa feliz e com o coração aquecido. Dá vontade de dizer que a web está passando por uma fase estranha, mas não me lembro de uma época em que a web não fosse estranha

    • Como o Impact originalmente era um engine baseado em JavaScript e navegador, imagino que ele já devesse rodar bem no Android só com uma WebView simples
  • CrossCode é um jogo excelente. Eu sabia que ele usava tecnologias web, e continuava me impressionando como o desempenho ficava tão bom no hardware do Nintendo Switch
    Acho que este engine também deve ter uma certa participação nisso

    • Para ser justo, a equipe de CrossCode modificou muito o Impact. Em algumas transmissões de desenvolvimento dá para ver que o Weltmeister, o editor de fases do Impact, foi bastante expandido: https://youtu.be/4lZfnM9Ubeo?t=3215
      Isso, na verdade, torna tudo ainda mais legal. É bom que o desenvolvedor possa adaptar o engine ao próprio jogo e, da mesma forma, o high_impact deve ser visto mais como um ponto de partida conveniente do que como um engine de jogo “completo em recursos”
    • O port para Switch, como alguém já mencionou, exigiu muito trabalho e é completamente diferente do impact.js padrão
      Como curiosidade, todo mundo queria uma versão para Switch, mas por limitações técnicas a equipe respondeu que “CrossCode sairia no Switch quando os Hedgehags aprendessem a voar”: https://www.radicalfishgames.com/?p=6581
      No fim, quando o port aconteceu, entrou uma missão extra chamada “A switch in attitude” e, como esperado, aparecem hedgehags voadores: https://www.radicalfishgames.com/?p=6668
    • Há uma apresentação sobre o processo de portar CrossCode para o Switch: https://www.youtube.com/watch?v=KfBzlzvt8RU
    • Pelo que lembro, o port para Switch exigiu um nível heroico de esforço
  • “Thoughts on Flash” talvez tenha salvado a plataforma web no momento em que ela mais precisava, isto é, quando o domínio de um único software estava crescendo aos poucos
    Também parecia haver ali frustração com a Adobe, que dava prioridade à base de usuários muito maior do Windows e aparentemente negligenciava o suporte ao MacOS. Por exemplo, a versão para Mac sempre ficava atrás da versão para Windows
    Talvez Jobs sentisse que, assim como a Apple havia viabilizado a Adobe, a Adobe também devia algo à Apple, mas isso já é mais especulação. O jogo em si realmente parece muito polido

    • O PSP também tinha Flash, e era bem decente. Fico curioso para saber quanto esforço e dinheiro a Sony colocou nisso
    • Este texto me deu vontade de voltar a mexer com C. Originalmente eu era mais do lado do ECMAScript e, há muito tempo, mexi um pouco com Lingo, mas sou atraído por coisas de poucos recursos e mais próximas do hardware
      Só não tão próximas a ponto de chegar em assembly; estou mais no caminho de ir reduzindo e simplificando coisas de um jeito que me faça cavar mais fundo
  • Como alguém que quer sair do trabalho corporativo e um dia mergulhar de verdade em projetos paralelos, eu gostaria de ouvir mais sobre a parte de se sustentar com monetização
    A ideia de cobrar por algo que originalmente eu fazia por diversão parece estranhamente pesada, mas também sei que isso pode me permitir trabalhar em tempo integral com algo de que gosto

    • Se for um bom trabalho que resolve o problema de alguém, ser recompensado por isso é totalmente legítimo, e você provavelmente já sabe disso
      Por isso, é importante entender por que essa ideia parece pesada. Motivos comuns incluem pessoas ao seu redor frequentemente te desencorajarem, faltar habilidade técnica para executar bem o que você quer fazer, sentir vergonha de pedir ajuda ou achar que vai incomodar os outros, ter medo de que o seu trabalho seja julgado, ou perder a “segurança” da renda atual, especialmente se você tem dependentes
      Na maioria dos casos, esses motivos não são exatamente ótimos motivos, e sim algo que precisa de certo reajuste e acaba parecendo risco, tornando difícil sair da zona de conforto. Com essa mentalidade, toda oportunidade parece um risco, então fica muito difícil encontrar o momento certo para começar a fazer o que você realmente quer
      Sobre isso, é importante simplesmente fazer algo que você acha divertido e mostrar ao mundo, mas transformar isso em sustento é um desafio completamente diferente. A maioria das pessoas não consegue transformar o que ama em profissão e, mesmo quando consegue, as expectativas de clientes pagantes e a pressão para manter a receita podem tirar esse carinho. Não estou dizendo para não fazer; só é bom saber disso antes de mergulhar
  • Cheguei a entrar na minha conta do HN, que quase nunca uso, porque há alguns anos eu jogava Biolab Disaster repetidamente, mas tinha esquecido o nome
    Foi bem curioso acabar reencontrando por acaso

  • Normalmente eu expressava isso de forma bem mais negativa. Eu via “framework” como apenas uma biblioteca que não se dá bem com outras
    Ainda assim, foi bom ouvir uma formulação positiva tão convincente

    • A explicação que eu tinha ouvido era que a biblioteca é chamada por mim, enquanto o framework me chama
    • Estudei engenharia de software, mas só entendi direito a diferença entre biblioteca e framework quando desenvolvi com WebObjects no meu primeiro emprego
      Foi um prazer usar um framework rico e bem projetado que cuidava muito bem de 99% do que eu precisava. Adicionar meu código também foi uma das experiências de desenvolvimento mais fáceis que já tive, e simplesmente funcionava. Parecia mágica, e ainda sinto falta disso
    • Ainda acho que existe um lado negativo
      O framework ideal, para mim, é internamente uma biblioteca ou um conjunto de bibliotecas que cooperam entre si, com o mínimo possível de características de framework
      Por exemplo, o Qt é um framework e o Qt “me chama”, mas ainda assim dá para executar código com QPainter sem iniciar o loop de eventos do Qt nem pensar profundamente em QObject. Idealmente, deveria ser possível usar o loop de eventos sem adotar totalmente sinais e slots, embora a experiência possa ficar pior
      Nem sempre isso é possível, nem sempre vale a pena, mas, se todo o resto for igual, prefiro a opção sem framework nenhum
      Entendo por que um pouco de framework é necessário em engines de jogo. Ao compilar para plataformas peculiares como celular ou console, a engine precisa interferir no processo de build e, às vezes, até em partes relacionadas à libc, então não dá para simplesmente gerar um executável Win32 e dizer que é um jogo de PlayStation
  • Combinar o formato de arquivo sem perdas QOI com 7Zip tem desempenho melhor do que PNG sem perdas. Trabalho impressionante

    • Fazer o mesmo com BMP e 7Zip também seria melhor, e a diferença talvez fosse ainda maior. No fim das contas, isso se resume à diferença entre gzip e compactadores substitutos do gzip
  • “for No Reason” talvez tenha sido para respeitar a duração da bateria do jogador

  • Gosto da parte de gerenciamento de memória. Alocação em arena é realmente simples
    Meu servidor web de brinquedo também começou com arenas, mas logo percebi que nem havia necessidade de aumentar e diminuir a memória
    Agora eu aloco toda a memória necessária logo no início e depois a divido em partes para cada módulo usar. Ao programar, muitas vezes assumimos que podemos precisar de uma quantidade arbitrária de memória, mas isso não é necessariamente verdade
    Muitas coisas de fato têm limites claros, e para o restante normalmente também dá para definir limites. Se você os enumerar, descobre quanta memória precisa. Pensar e definir esses limites com antecedência é divertido, dá confiança e incentiva hábitos saudáveis de economia

    • Sempre fico irritado quando dizem que o desempenho de std::vector é terrível, especialmente em jogos
      Claro que pode ser, se você começar com um vetor vazio e adicionar milhares de itens sem reservar memória. Mas há muitos casos em que dá para descobrir o máximo realmente necessário e alocar antes, e aí fica tudo bem
    • O banco de dados TigerBeetle foi feito dessa forma, e eu gosto bastante da ideia: https://tigerbeetle.com/blog/a-database-without-dynamic-memo...
  • Gostei muito da forma como foi criada uma estrutura ENTITY de tipo polimórfico com union. O design é bom
    Ainda gosto de mexer com C. Foi a primeira linguagem que aprendi, e passei por bastante perrengue com ela durante alguns anos. Como o texto original também diz, C é uma linguagem elegante justamente por ser concisa, e dá para se aprofundar o quanto quiser
    O jogo também foi legal por ter uma vibe de Commander Keen, e eu gostava bastante dessa franquia que o Carmack fazia antes da era 3D