3 pontos por GN⁺ 2024-06-07 | Ainda não há comentários. | Compartilhar no WhatsApp
  • 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
  • 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-bootstrap reduz 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
  • volk simplifica o carregamento de extension functions
    • Por exemplo, permite usar funções de extensão como vkSetDebugUtilsObjectNameEXT sem precisar armazená-las manualmente como ponteiros
  • A classe GfxDevice agrupa funções e objetos de Vulkan usados com frequência
    • Inicialização do contexto Vulkan
    • Criação e gerenciamento de swapchain
    • beginFrame e endFrame
    • Criação de imagens e carregamento de textures
    • Criação de buffers
    • Gerenciamento de bindless descriptor sets
  • GfxDevice.cpp tinha 714 linhas no momento da escrita, e passar um único objeto foi considerado mais prático do que repassar VkDevice, VkQueue, VmaAllocator e 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 glslc permite especificar DEPFILE no 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, inicializar VkPipeline e VkPipelineLayout
    • cleanup: limpar pipeline e layout
    • draw: receber as entradas necessárias a cada frame e executar a draw call
  • Assume-se que draw é chamado entre vkCmdBeginRendering e vkCmdEndRendering
  • 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 VkVertexInputBindingDescription e VkVertexInputAttributeDescription no 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++
  • Bindless descriptor é usado com um grande descriptor set contendo arrays de textures e samplers
    • Quando uma nova texture é carregada, ela é colocada no array textures e seu índice é usado como bindless texture id
    • No shader, o texture id é passado por push constants
  • 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 vkconfig ajuda 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 SpriteDrawBuffer como SpriteDrawCommand
    • 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
  • MeshDrawCommand inclui meshId, 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, Interact antes do primeiro ponto é o nome do prefab
    • Sphere é usado pelo sistema de física ao criar um corpo físico esférico
    • Capsule e Box também podem ser usados; se não houver nenhum deles, o physics shape é gerado a partir dos vértices da malha
  • modelos complexos não são colocados diretamente no glTF da fase; eles são posicionados como objetos Empty->Arrows e recebem nomes como Cat.NearStore
    • isso cria o prefab Cat e adiciona a tag NearStore
  • 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

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::CharacterVirtual lida 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.

Ainda não há comentários.