- Um desenvolvedor com experiência em OpenGL aprendeu Vulkan pela primeira vez e, em cerca de 3 meses, criou dois pequenos demos de jogos e o motor reutilizável EDBR
- Em vez de projetar um motor genérico desde o início, ele fez primeiro jogos pequenos e depois transformou em motor apenas as partes necessárias, reduzindo excesso de design e bike-shedding
- O motor tem 19k LoC, com 6,7k LoC de código gráfico e 2k LoC de abstração leve de Vulkan, incluindo compute skinning, CSM, sombreamento PBR, MSAA, post FX e renderização de UI
- Usa
vk-bootstrap, Vulkan Memory Allocator,volk,VK_KHR_dynamic_rendering, push constants, buffer device address e bindless descriptors para reduzir boilerplate de Vulkan e o uso de descriptor sets - Vulkan trouxe remoção de estado global, erros de validação melhores, depuração de shaders no RenderDoc e consistência entre GPU e SO, mas a sincronização explícita ainda precisa ser tratada manualmente
O motor EDBR baseado em Vulkan criado em 3 meses
- O EDBR (Elias Daler’s Bikeshed Engine) começou como um projeto de aprendizado de Vulkan e depois evoluiu para um pequeno motor reutilizável em projetos futuros
- O código do motor e dos jogos está disponível no repositório no GitHub
- No momento da escrita, o tamanho do código era o seguinte
- Motor em si: 19k LoC
- Código relacionado a gráficos: 6,7k LoC
- Abstração leve de Vulkan: 2k LoC
- Jogo 3D do gato: 4,6k LoC
- Jogo de plataforma 2D: 1,2k LoC
- Motor em si: 19k LoC
- Parte do código não gráfico, como tratamento de entrada e sistema de áudio, foi trazida de um motor anterior, mas os gráficos e vários sistemas centrais foram escritos do zero
- A avaliação foi que fazia mais sentido reescrever do zero do que tentar encaixar Vulkan na abstração existente de OpenGL
Sequência de aprendizado: da programação gráfica até Vulkan
- Para quem está começando em programação gráfica, é mais fácil começar com OpenGL do que com Vulkan, para não ser engolido pela complexidade
- Como objetivo mínimo, recomenda-se conseguir exibir na tela um modelo com textura e implementar uma iluminação simples de Blinn-Phong
- O shadow mapping básico ajuda a aprender como renderizar a cena de outro ponto de vista e para outro alvo de renderização, além de como amostrar uma depth texture
- Os seguintes materiais são recomendados para aprender OpenGL
- Para aprender Vulkan, vkguide foi o recurso mais útil; para quem está começando, vale seguir o conteúdo inteiro, embora um jogo simples talvez não precise imediatamente da complexidade do nível de “GPU driven rendering”
- A Vulkan Lecture Series by TU Wien cobre os fundamentos de Vulkan, e as aulas sobre sincronização são especialmente úteis
- No primeiro mês de aprendizado, foram implementados os seguintes recursos
- Carregamento de modelos glTF
- Compute skinning
- frustum culling
- shadow mapping e cascaded shadow maps
Por que escolher Vulkan e comparação com WebGPU
- O objetivo era um pequeno jogo 3D para desktop, focado em Windows e Linux, e a escolha entre OpenGL e Vulkan levou em conta a preferência por tecnologias open source e padrões abertos
- OpenGL é suficiente para jogos pequenos, mas foi considerado incerto para o futuro, já que dificilmente ganhará novas versões e está deprecated no macOS
- WebGPU também foi estudado um pouco, mas havia as seguintes limitações
- Ainda não é estável e não há muitos tutoriais e exemplos
- A sintaxe de WGSL agradou menos do que a de GLSL
- No desktop, ele é mais próximo de um wrapper sobre DirectX, Vulkan e Metal, então as capturas no RenderDoc variam por plataforma e as chamadas WebGPU não correspondem 1:1 às chamadas da API nativa
- Não há bindless textures nem push constants
- WebGPU também tem vantagens claras
- Tem erros de validação melhores que OpenGL/WebGL e não usa estado global
- Tem semelhanças com Vulkan, então ajuda antes de aprender Vulkan
- Exige menos boilerplate que Vulkan para colocar algo na tela
- Não exige lidar diretamente com sincronização explícita
- Permite rodar jogos no navegador
Fluxo de renderização de um frame
- Um frame é dividido em várias etapas, e cada uma é implementada na forma de pipeline ou pass
- Na etapa de skinning, modelos com skeletal animation são processados com compute shader
- A entrada é uma mesh sem skinning e joint matrices
- A saída é um vertex buffer usado nas etapas seguintes de renderização
- Depois disso, as etapas seguintes podem tratar mesh estática e mesh com skinning de forma parecida
- Na etapa de CSM, o cascaded shadow mapping é executado com uma depth texture 4096x4096 e 3 slices
- Na etapa de geometry + shading, os modelos são desenhados e sombreados usando o shadow map e as informações de luz
- Os modelos PBR seguem quase exatamente a abordagem descrita em Physically Based Rendering in Filament
- O fragment shader calcula, em uma única draw call, todas as luzes que afetam a mesh em questão
- Tudo é desenhado em uma multi-sampled texture e depois passa por resolve
- O depth resolve é tratado manualmente no fragment shader
- Ele percorre todos os fragments da multi-sample depth texture e grava o valor mínimo em uma depth texture sem MS
- A etapa de post FX atualmente aplica apenas depth fog, e há planos de tratar também tone mapping e bloom nessa fase
- Na etapa de UI, a interface de diálogo é desenhada e processada em uma única draw call
Bibliotecas para reduzir o boilerplate de Vulkan
vk-bootstrapreduz o boilerplate de inicialização do Vulkan, como seleção de physical device e criação de swapchain- Em vez de ser um wrapper completo das funções de Vulkan, ele atua principalmente na fase de inicialização
- Vulkan Memory Allocator evita a necessidade de lidar manualmente com alocação de memória no Vulkan
volksimplifica o carregamento de extension functions- Por exemplo, permite usar funções de extensão como
vkSetDebugUtilsObjectNameEXTsem precisar armazená-las manualmente como ponteiros
- Por exemplo, permite usar funções de extensão como
- A classe
GfxDeviceagrupa funções e objetos de Vulkan usados com frequência- Inicialização do contexto Vulkan
- Criação e gerenciamento de swapchain
beginFrameeendFrame- Criação de imagens e carregamento de textures
- Criação de buffers
- Gerenciamento de bindless descriptor sets
GfxDevice.cpptinha 714 linhas no momento da escrita, e passar um único objeto foi considerado mais prático do que repassarVkDevice,VkQueue,VmaAllocatore outros por vários lugares
Build de shader e estratégia para evitar descriptor sets
- A linguagem de shader escolhida foi GLSL, por causa da experiência prévia com OpenGL
- Os shaders são compilados para SPIR-V na etapa de build, não em runtime
- O código de carregamento de shader em runtime fica mais simples
- Não há dependência de um compilador de shader em runtime
- Erros de shader são encontrados na etapa de build
- O
glslcpermite especificarDEPFILEno CMake, então alterações em includes de shader podem recompilar automaticamente os arquivos relacionados - No Vulkan, é preciso agrupar uniforms em descriptor sets, o que torna o envio de dados mais complexo do que no OpenGL
- Nesta implementação, o uso de descriptor sets foi bastante reduzido
- É usado apenas um descriptor set global para texture e sampler bindless
- O restante é passado, na maior parte, por push constants
- Usa-se buffer device address para passar endereços de buffer por push constants
Classe de pipeline e dynamic rendering
- As etapas de renderização foram separadas em classes de pipeline, como
PostFXPipeline - Cada pipeline normalmente tem os seguintes papéis
init: carregar shaders, inicializarVkPipelineeVkPipelineLayoutcleanup: limpar pipeline e layoutdraw: receber as entradas necessárias a cada frame e executar a draw call
- Assume-se que
drawé chamado entrevkCmdBeginRenderingevkCmdEndRendering - O pipeline não se preocupa internamente com em qual texture o render pass está desenhando; quem chama decide o render target
VK_KHR_dynamic_renderingé usado de forma geral, e Vulkan render pass e subpass não são utilizados- Foi mencionado que render pass e subpass são mais eficientes em GPUs tile-based, mas no momento não há preocupação com suporte mobile
- Dynamic rendering torna a implementação muito mais simples
Uso de PVP, BDA e bindless descriptor
- Um único tipo de vertex é usado para todos os meshes
- Com programmable vertex pulling, dá para evitar definições de formato de vertex como VAO no OpenGL ou
VkVertexInputBindingDescriptioneVkVertexInputAttributeDescriptionno Vulkan - Com buffer device address, é possível passar o vertex buffer por endereço de buffer via push constants, sem fazer bind em descriptor set
- Push constants e buffers usam layout
scalar- É mais fácil lidar com alinhamento do que em
std430, então o uso fica quase igual ao de structs em C++ - Reduz a necessidade de membros de padding em structs C++
- É mais fácil lidar com alinhamento do que em
- Bindless descriptor é usado com um grande descriptor set contendo arrays de textures e samplers
- Quando uma nova texture é carregada, ela é colocada no array
texturese seu índice é usado como bindless texture id - No shader, o texture id é passado por push constants
- Quando uma nova texture é carregada, ela é colocada no array
- Os samplers são separados das images; samplers comuns são criados no startup e colocados no array
samplers - O material buffer também usa bindless texture id
- Só o material ID é passado por push constants, e o fragment shader consulta o material buffer
- Isso permite acessar textures com um único inteiro por material, sem descriptor sets pesados
- Como material de referência sobre bindless texture, é recomendado Vulkan Bindless Texture
Dados dinâmicos enviados a cada frame
- Para dados que precisam ir da CPU para a GPU a cada frame, usa-se a abordagem de pré-alocar um grande array e preenchê-lo a cada frame a partir do índice 0
- Por exemplo, todas as joint matrices são armazenadas em um grande array de
mat4, e para cada skinned mesh o índice inicial é passado por push constants - Há duas abordagens
- Manter N buffers na GPU e alterná-los por frame-in-flight
- Manter apenas um buffer na GPU e N staging buffers no lado da CPU
- Na maioria dos casos, a primeira abordagem é recomendada
- Ela usa mais memória de GPU, mas não exige sincronização manual
- Se for necessário economizar memória de GPU, a segunda abordagem pode ser útil
- Não foi observada diferença de desempenho perceptível entre as duas, mas isso pode mudar ao enviar volumes muito grandes de dados a cada frame
Cleanup e sincronização
- O padrão de deletion queue do vkguide não trouxe muito benefício no motor do autor
- Porque novos objetos Vulkan não são alocados ou destruídos a cada frame
- O cleanup baseado em destructors de C++ também não foi confortável
- Exige wrapper class, move constructor e move assignment, aumentando a complexidade
- Há o risco de remover acidentalmente um objeto em uso no meio do frame ao destruir um wrapper
- Atualmente, objetos Vulkan são limpos com chamadas explícitas à função
cleanup, tudo em um único lugar- É fácil esquecer de chamar, mas no encerramento os Vulkan validation errors e asserts do VMA indicam limpezas que ficaram faltando
- A synchronization do Vulkan é difícil e precisa ser gerenciada explicitamente
- OpenGL e WebGPU cuidam da sincronização necessária para leitura de textures e buffers
- No Vulkan, é preciso inserir barriers manualmente para evitar data races
- No momento, os draws são divididos em passes e pipelines, com barriers inseridos manualmente entre eles
- Por exemplo, depois que o pass de skinning com compute shader escreve os dados de vertex, é inserido um barrier antes de o pass de shadow mapping lê-los
- Isso pode ser automatizado com um render graph, mas por enquanto a sincronização manual é suficiente
- A synchronization validation layer do
vkconfigajuda a encontrar erros de sincronização
Sprite, skinning e separação entre game e renderer
- Com bindless texture, fica fácil desenhar muitos sprites em uma única draw call sem vertex buffer
- O vertex shader de sprite gera as coordenadas do quad e os UVs com
gl_VertexIndex - Todas as draw calls de sprite são reunidas em
SpriteDrawBuffercomoSpriteDrawCommand- transform
- intervalo de UV
- cor
- texture ID
- shader ID
- A draw call real tem a forma
vkCmdDraw(cmd, 6, spriteDrawCommands.size(), 0, 0)- 6 vertices por sprite
- uma instância para cada sprite
- O sprite renderer consegue desenhar 10 mil sprites em 315 microssegundos
- O skinning por compute recebe vertices de entrada e joint matrices de meshes com animação esquelética e gera um skinned vertex buffer
- Mesmo 3 gatos com o mesmo mesh podem ter animações diferentes
- O vertex buffer de saída é necessário para cada instância do mesh
- A lógica de jogo e o renderer são separados por draw commands
- A lógica de jogo usa entt
- O renderer não conhece entities nem game objects; ele lida apenas com luzes, parâmetros de cena e mesh draw commands
MeshDrawCommandincluimeshId, matriz de transformação, bounding sphere, ponteiro para skinned mesh, índice inicial das joint matrices e se projeta sombra
carregamento de cena, UI, Dear ImGui
- em vez de criar um editor de fases do zero, foi usado o Blender e feita a exportação em glTF
- escrever um editor de fases do zero poderia levar meses ou anos, então isso economiza tempo
- nomes de nós são usados para definir a criação de prefabs e physics shapes
- exemplo: em
Interact.Sphere.Diary,Interactantes do primeiro ponto é o nome do prefab Sphereé usado pelo sistema de física ao criar um corpo físico esféricoCapsuleeBoxtambém podem ser usados; se não houver nenhum deles, o physics shape é gerado a partir dos vértices da malha
- exemplo: em
- modelos complexos não são colocados diretamente no glTF da fase; eles são posicionados como objetos
Empty->Arrowse recebem nomes comoCat.NearStore- isso cria o prefab
Cate adiciona a tagNearStore
- isso cria o prefab
- os prefabs são escritos em JSON e podem incluir glTF externo, além de informações de movimento e física
- o sistema de UI foi inspirado na API de UI do Roblox
- origem
- tamanho relativo
- posição relativa
offsetPosition,offsetSize- tamanho fixo
- tamanho baseado no conteúdo de label/image
- primeiro o tamanho dos elementos de UI é calculado recursivamente, depois a posição é calculada, e então eles são desenhados do parent para os children
- Dear ImGui é usado para ferramentas de desenvolvimento e depuração
- houve um problema em que o Dear ImGui aparecia incorretamente em um framebuffer sRGB, então foi escrito um backend próprio para Dear ImGui
- apenas a parte de rendering foi escrita; o backend SDL padrão do Dear ImGui continua cuidando do tratamento de eventos de entrada, clipboard e outras interações de lógica/SO
- as vantagens do backend próprio são as seguintes
- suporta bindless texture id, então é possível desenhar imagens com
ImGui::Image(bindlessTextureId, ...) - imagens lineares e não lineares podem ser desenhadas corretamente passando o formato
- pode ser inicializado e tratado da mesma forma que o restante do código Vulkan do motor
- suporta bindless texture id, então é possível desenhar imagens com
bibliotecas usadas e os efeitos da transição para Vulkan
- para física, foi usado Jolt Physics
- principalmente para resolução de colisões e movimentação básica de personagem
JPH::CharacterVirtuallida bem com a movimentação básica de personagem
- para ECS, foi usado entt
- usar uma biblioteca externa em vez de um ECS próprio reduz a quantidade de código a manter
- para áudio, foram usados openal-soft, libogg e libvorbis
- para profiling, foi usado Tracy
- isso ajuda a verificar quanto tempo certo código realmente consome e evita bike-shedding desnecessário
- os ganhos obtidos com a transição para Vulkan foram os seguintes
- a ausência de estado global do OpenGL facilita a abstração
- há menos necessidade de coisas como
shader.bind()ao estilo OpenGL, state tracker e magic RAII - os validation errors são mais ricos do que no OpenGL
- no RenderDoc, é possível depurar diretamente o vertex shader e o fragment shader
- as diferenças de comportamento entre GPU e SO são menos acentuadas do que no OpenGL
- no futuro, será possível explorar outras shading languages, como Slang e Shady
- é possível controlar mais aspectos do pipeline gráfico
trabalhos futuros
- os trabalhos planejados para o futuro são os seguintes
- suporte a fontes sign-distance field
- carregamento de muitas imagens e geração paralela de mipmaps
- bloom
- volumetric fog
- animation blending
- render graph
- ambient occlusion
- concluir o jogo
- aprender Vulkan foi difícil, mas não tanto quanto o esperado, e serviu como uma oportunidade para entender mais profundamente programação gráfica e APIs modernas
Ainda não há comentários.