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.

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.
proj = proj * 0.5 + 0.5;proj.xy = proj.xy * 0.5 + 0.5;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:
TransformSystem
atualiza as matrizes de mundo.SyncFromWorld
cria, remove e empurra para o Jolt o que mudou.PhysicsWorld::Step
roda a simulação.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:
{ "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.

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.
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
- 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.
- Separe “montar entidades” de “carregar recursos”. Injetar o resolvedor de modelo foi o que deixou o spawner testável sem GPU.
- 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.
- 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.