← voltar ao blog
ACERTOjogos6min de leituramin

Black Site: um dia de motor próprio em Vulkan

O que é o Black Site, o jogo que estou construindo em C++ com Vulkan, e tudo que saiu do papel em um único dia: materiais, sombras, ECS, física, cenas em JSON, UI e um personagem que anda.

Matheus Nunes PereiraEngenheiro de Software

Se você leu o Sobre mim, sabe que meu objetivo de longo prazo é construir jogos próprios e que hoje estudo arquitetura de game engine e renderização 3D. O Black Site é onde isso sai do discurso e vira código. Este post é o primeiro registro do projeto: explico o que ele é e depois conto o que implementei hoje.

O que é o Black Site

É um jogo em primeira pessoa que escrevo do zero em C++20, com Vulkan para o gráfico e CMake para o build. Eu já tinha uma versão anterior com menus e HUD, mas no fim de setembro reescrevi o motor em Vulkan. Quero entender o caminho inteiro até o pixel na tela, sem uma engine escondendo as etapas.

A meta que guia tudo é simples de dizer e difícil de cumprir: o que eu exportar do Blender precisa chegar no jogo igual. Mesma escala, mesma orientação, mesmo material, mesma iluminação. Para chegar lá, trabalho em etapas pequenas e só começo a próxima quando a atual está funcionando e testada.

As peças de terceiros são poucas e escolhidas a dedo: cgltf para ler glTF, Jolt para física, RmlUi para a UI do jogo e Dear ImGui só para debug.

Cena do Black Site com o trono de caveira, caixas com wireframe de colisão e os painéis de debug abertos
A cena depot rodando, com os painéis de debug: configurações, cena, ECS e performance.

O que foi feito hoje

Foi um dia longo e rendeu bastante. Vou contar cada parte na ordem em que fez sentido construir: primeiro o que aparece na tela, depois o ECS e a física, as cenas, a interface e por fim o personagem.

Materiais e sombras

Comecei pelo que mais aparece na tela. O renderer agora lê o material do glTF com base color, metallic, roughness e normal map, e escolhe a variante de pipeline certa para objetos com alpha ou de dois lados. O cálculo de luz é Cook-Torrance, com GGX, Smith e Schlick.

Depois veio um bug bem característico de quem escreve o próprio renderer: ao ligar as sombras, o chão inteiro dentro do frustum da luz ficava escuro, como se tudo estivesse dentro de uma sombra gigante que girava junto com o sol.

Antes
proj = proj * 0.5 + 0.5;
Depois
proj.xy = proj.xy * 0.5 + 0.5;
O remapeamento do shadow map no fragment shader

O projeto compila com GLM_FORCE_DEPTH_ZERO_TO_ONE, então o z de clip já vem em [0, 1]. Eu estava aplicando * 0.5 + 0.5 nos três componentes, o que empurrava o z de referência para [0.5, 1]. Comparado com a profundidade gravada no shadow map, todo fragmento parecia estar atrás de alguma coisa.

Junto com a correção coloquei o hot reload de modelos: o engine confere o mtime dos .glb a cada 0,5 s, reimporta na CPU e só depois libera os recursos antigos da GPU. Exportar do Blender e ver o resultado no jogo sem recompilar muda o ritmo de trabalho.

ECS, luz e física

A luz saiu das configurações de debug e virou um DirectionalLightComponent. Um LightSystem junta isso em um snapshot (LightingState) por frame, e o renderer consome só esse snapshot, sem saber nada sobre o layout do ECS. Isso deixa a coleta de luz testável sem precisar de GPU.

Na sequência integrei o Jolt. Um PhysicsSyncSystem cria os corpos a partir de um ColliderComponent, avança a simulação e devolve a pose para o TransformComponent. O colisor é autorado à parte e nunca usa o mesh de render. A ordem do passo fixo importa bastante:

  1. TransformSystem

    atualiza as matrizes de mundo.
  2. SyncFromWorld

    cria, remove e empurra para o Jolt o que mudou.
  3. PhysicsWorld::Step

    roda a simulação.
  4. SyncToWorld

    lê a pose dos corpos dinâmicos e escreve no transform.

Um detalhe que me pegou: se eu escrevo a pose do transform em um corpo dinâmico a cada passo, ele nunca dorme e a simulação briga com o jogo. A solução foi guardar a última pose que veio do corpo. Se o transform ficou diferente dela, alguém editou de fora (o painel de debug, por exemplo), e só aí aplico como teleporte.

Cena em JSON

O nível deixou de ser código. Agora um prefab descreve uma entidade e uma cena compõe instâncias deles, com includes, environment e overrides:

resources/scenes/depot.scene.json
{
"id": "depot",
"version": 1,
"environment": {
"sun": { "direction": [-0.4, -1.0, -0.3], "intensity": 3.0 }
},
"instances": [
{ "name": "ground", "prefab": "ground", "transform": { "position": [0, 0, 0] } },
{ "name": "throne", "prefab": "skull-throne", "transform": { "position": [4, 3, -2] } }
]
}

O loader é determinístico e as mensagens de erro dizem o arquivo e o campo. Para testar o spawner sem Vulkan, ele recebe um resolvedor de modelo por callback: no jogo entra o AssetManager, no teste entra um stub.

UI do jogo

O HUD e o menu de pausa agora são documentos .rml e .rcss do RmlUi, desenhados no fim da swapchain, por cima da cena e por baixo do ImGui. O RmlUi entrega cor com alpha pré-multiplicado, então o blend tem que ser ONE, ONE_MINUS_SRC_ALPHA, e como a swapchain é sRGB as cores de vértice também precisam ser decodificadas, senão tudo estoura o brilho.

Menu de pausa do Black Site sobre a cena, com o HUD de vida e stamina no canto inferior esquerdo
O menu de pausa e o HUD, ambos documentos RmlUi.

Um personagem que anda

Por último, troquei a cápsula cinemática por um JPH::CharacterVirtual. Agora o player anda com W A S D, corre, pula e sobe rampas de até 50 graus. A tecla F liga e desliga o noclip, que é o que mais uso para inspecionar a cena.

A posição do CharacterVirtual é a dos pés, e a câmera fica nos olhos, a 1,7 m. Se os dois forem confundidos, a câmera fica flutuando 1,7 m acima do chão. E para os props enxergarem o jogador, precisei de um corpo interno um pouco menor, na camada MOVING, porque o personagem virtual sozinho não existe no broadphase.

Voando em noclip pela cena, com os colisores e os painéis de debug ligados.

O que ficou de fora

Fica registrado o que adiei de propósito: colisores convexos e trimesh, eventos de colisão e trigger, e empurrar props com o player. Cada um vai ganhar o seu momento.

O que aprendi

  1. Defina quem manda na pose antes de ligar a física. Dinâmico é da simulação, estático e cinemático são do jogo, e qualquer mudança externa é um teleporte explícito.
  2. Separe “montar entidades” de “carregar recursos”. Injetar o resolvedor de modelo foi o que deixou o spawner testável sem GPU.
  3. Quando a sombra gira junto com o sol e cobre o frustum inteiro, desconfie da matemática do shader. Aqui era um remapeamento duplicado de profundidade.
  4. Anotar cada surpresa na hora, num caderno de aprendizados do projeto, vale mais do que parece. Já passam de 40 anotações, e boa parte deste post saiu delas.

O próximo passo são ferramentas para enxergar o que o engine está fazendo por dentro, sem precisar adivinhar. Quando sair, conto aqui.

Neste post
Escrito por Matheus Nunes PereiraEngenheiro de Software. Sistemas distribuídos, telemetria em tempo real e software que conversa com hardware. Se tiver um problema assim, vamos conversar.Engenheiro de Software

Sistemas distribuídos, telemetria em tempo real e software que conversa com hardware. Se tiver um problema assim, vamos conversar.