Link

Objetos Vulkan

O Vulkan é uma API que consiste em um conjunto de objetos e funções que são usados para programar uma GPU. Esta seção descreve os principais tipos de objetos que são definidos nela, o que eles representam e como eles se relacionam uns com os outros. Para ajudar com isso, o diagrama da Figura 1 mostra os principais objetos Vulkan que vamos utilizar direta ou indiretamente neste projeto e alguns de seus relacionamentos, especialmente a ordem na qual criamos um a partir do outro.

Figura 1 - Principais objetos Vulkan e alguns de seus relacionamentos
Fonte: Adaptado de Sawicki (2017).


Cada objeto Vulkan é um valor de um tipo específico com o prefixo Vk. Esses tipos não devem ser tratados como ponteiros ou números ordinais. Não devemos interpretar seus valores de maneira alguma. Basta tratá-los como tipos opacos, passá-los de uma função para outra e, claro, não podemos nos esquecer de destruí-los quando não forem mais necessários. Na Figura 1, retângulos com um fundo branco não têm seus próprios tipos. Em vez disso, eles são representados pelo índice numérico do tipo uint32_t dentro de seu objeto pai, como VkQueueFamily dentro do objeto VkPhysicalDevice.

Linhas sólidas com extremo em forma de um losango representam composição, o que significa que não precisamos criar esse objeto, mas ele já existe dentro de seu objeto pai e pode ser obtido a partir dele. Por exemplo, podemos enumerar objetos do tipo VkPhysicalDevice de um objeto VkInstance. Linhas cheias com setas representam a ordem de criação. Por exemplo, devemos especificar um objeto VkCommandPool existente para criar um objeto VkCommandBuffer. Linhas tracejadas representam outras relações, como submeter buffers de comandos para um objeto VkQueue.

O diagrama é dividido em três seções. Cada seção tem um objeto principal, mostrado em fundo azul. Todos os outros objetos em uma seção são criados, direta ou indiretamente, a partir desse objeto principal. Por exemplo, vkCreateImage – a função que cria um objeto VkImage - toma o objeto VkDevice como seu primeiro parâmetro. Relacionamentos com objetos principais não são desenhados neste diagrama para maior clareza.

Veremos, a seguir, uma breve descrição de todos os objetos mostrados no diagrama.

VkInstance

A primeira tarefa que precisamos fazer é inicializar a biblioteca Vulkan criando um objeto to tipo VkInstance (instância). Esse objeto é a conexão entre nosso aplicativo e a biblioteca Vulkan e sua criação envolve a especificação de alguns detalhes sobre a nossa aplicação para o driver. Ele também armazena todo o estado específico do aplicativo necessário para usar o Vulkan. Portanto, devemos especificar todas as camadas (como a camada de validação que veremos mais adiante) e todas as extensões que desejamos ativar ao criar um objeto VkInstance.

VkPhysicalDevice

Depois de inicializar a biblioteca Vulkan através do objeto VkInstance, podemos consultar o hardware suportado pelo Vulkan e selecionar um ou mais objetos do tipo VkPhysicalDevice (dispositivo físico) para usar nas operações. Podemos consultar propriedades como tamanho da VRAM e recursos do dispositivo para selecionar os dispositivos desejados, por exemplo, para preferir usar placas gráficas dedicadas.

VkMemoryHeap, VkMemoryType

Um objeto VkPhysicalDevice pode enumerar heaps de memória e os tipos de memória dentro deles. Um heap representa uma parte específica da RAM. VkMemoryHeap é o objeto que descreve um dos heaps de RAM disponíveis com os quais o dispositivo pode conversar. Em particular, um objeto VkMemoryHeap descreve o número de bytes desse armazenamento de RAM e a localização do armazenamento em relação ao dispositivo (local vs. não local). Devemos especificar o tipo de memória ao alocar memória. VkMemoryType é o objeto que descreve uma maneira específica de alocar memória. Ele contém requisitos específicos para a área de memória como visíveis para o host, coerentes (entre CPU e GPU) e armazenados em cache. Pode haver uma combinação arbitrária destes, dependendo do driver do dispositivo.

VkDevice

Depois de selecionar o dispositivo de hardware correto para usar, precisamos criar um objeto VkDevice (dispositivo lógico), onde descrevemos mais especificamente quais recursos do dispositivo físico usaremos, como compressão de textura, suporte a floats de 64 bits (doubles) no código do shader, renderização de múltiplas viewports (útil para VR), etc.

VkQueue, VkQueueFamily

A maioria das operações realizadas com o Vulkan, como comandos de desenho e operações de memória, é executada de forma assíncrona, submetendo essas operações a um objeto VkQueue, que representa uma fila. Filas são alocadas a partir de objetos do tipo VkQueueFamily (família de filas), onde cada família de filas suporta um conjunto específico de operações em suas filas. Por exemplo, pode haver famílias de filas separadas para operações de gráficos, computação e transferência de memória. Com essa separação de filas é possível ativar a computação assíncrona, o que pode levar a uma velocidade substancial se for feita corretamente.

Os tipos disponíveis de famílias de fila são obtidos a partir do objeto VkPhysicalDevice. E é por isso que a disponibilidade de famílias de filas também pode ser usada como um fator diferencial na seleção de dispositivos físicos.

VkCommandBuffer

Comandos no Vulkan, como operações de desenho e transferências de memória, não são executados diretamente usando chamadas de função. Precisamos registrar todas as operações que desejamos executar em objetos do tipo VkCommandBuffer, que representam buffers de comando. A vantagem disso é que todo o trabalho de configurar os comandos de desenho pode ser feito com antecedência e em vários segmentos. Todo o trabalho a ser feito pela GPU é solicitado pelo preenchimento de objetos do tipo VkCommandBuffer e pelo envio deles às filas, usando a função vkQueueSubmit.

VkCommandPool

Os objetos do tipo VkCommandPool são objetos dos quais os buffers de comando adquirem sua memória e estão ligados a uma família de filas específica. A memória em si é alocada implicitamente e dinamicamente, mas sem ela, os buffers de comando não teriam nenhum espaço de armazenamento para manter os comandos gravados. É por isso que, antes de podermos alocar buffers de comando, primeiro precisamos criar um pool de memória para eles.

VkFence, VkSemaphore

Como no Vulkan a maioria das operações realizadas é executada de forma assíncrona existem os objetos usados para sincronização: VkFence e VkSemaphore2. Ambos são objetos que podem ser usados para coordenar operações por terem uma operação de sinalização e outra operação de espera. A diferença é que o estado de um objeto VkFence pode ser acessado a partir do nosso programa usando chamadas como vkWaitForFences e estado de um objeto VkSemaphore não pode ser. VkFence é projetado principalmente para sincronizar nosso próprio aplicativo com a operação de renderização, enquanto VkSemaphore é usado para sincronizar operações dentro ou através de filas de comandos.

2 Há também o objeto VkEvent que pode ser usado para inserir uma dependência entre os comandos enviados para uma mesma fila ou entre o host e uma fila. Porém, não iremos utilizá-lo nesse trabalho.

VkBuffer, VkImage

Na API Vulkan existem dois tipos de objetos para representar dados arbitrários na memória do dispositivo: VkBuffer e VkImage. VkBuffer é o mais simples. Ele é um contêiner para qualquer dado binário que tenha seu tamanho expresso em bytes. VkImage, por outro lado, representa um conjunto de pixels. Esse é o objeto conhecido em outras APIs gráficas como uma textura. Existem muitos outros parâmetros necessários para especificar a criação de um objeto VkImage como, por exemplo, seu tipo (1D, 2D ou 3D) e formato (como VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R32_UINT, entre outros). Além de poder ter várias camadas de array ou níveis de MIP.

VkDeviceMemory

VkDeviceMemory representa um bloco de memória alocado de um tipo específico de memória (como suportado pelo objeto VkPhysicalDevice) com um comprimento específico em bytes. Criar um objeto VkImage com dimensões específicas ou um objeto VkBuffer de determinado tamanho não aloca memória para ele automaticamente. É por isso que primeiro devemos alocar um objeto VkDeviceMemory. Em seguida, criar um objeto VkBuffer ou VkImage. Por fim, ligá-los juntos usando a função vkBindBufferMemory ou vkBindImageMemory.

Não é recomendado que aloquemos um objeto VkDeviceMemory separado para cada VkBuffer ou VkImage. Em vez disso, devemos alocar blocos de memória maiores e atribuir partes deles aos objetos do tipo VkBuffer e VkImage que vamos utilizar no nosso programa. Isso porque, a alocação é uma operação cara e também há um limite no número máximo de alocações, que podem ser consultadas no objeto VkPhysicalDevice (SELLERS; KESSENICH, 2016).

VkSampler

É possível que os shaders leiam pixels diretamente de imagens, mas isso não é muito comum quando eles são usados como texturas. Texturas são geralmente acessadas através de objetos do tipo VkSampler, que aplicarão filtragem e transformações para calcular a cor final que é recuperada. Esses filtros são úteis para lidar com problemas como oversampling, que ocorre quando uma textura é mapeada para uma geometria com mais fragmentos do que pixels, ou undersampling que é o problema oposto, onde temos mais pixels que fragmentos. Além desses filtros, um objeto VkSampler também pode cuidar de transformações. Ele determina o que acontece quando tentamos ler texels (pixels da textura) fora da imagem através de seu modo de endereçamento.

VkSurfaceKHR

Como o Vulkan é uma API independente de plataforma, não pode interagir diretamente com o sistema de janelas sozinho. Para estabelecer a conexão entre o Vulkan e o sistema de janelas para apresentar os resultados para a tela, precisamos usar as extensões WSI (Window System Integration).

Uma dessas extensões é VK_KHR_surface. Ela expõe um objeto do tipo VkSurfaceKHR que representa um tipo abstrato de superfície para apresentar imagens renderizadas. Podemos imaginar esse objeto como a representação Vulkan de uma janela. Como tal, a maneira de criá-lo depende da plataforma. Ele precisa do objeto VkInstance3, bem como de alguns parâmetros dependentes do sistema. Por exemplo, no Linux como detalhes de criação com X11 eles são: uma conexão XCB (xcb_connection_t) e uma janela (xcb_window_t).

3 O objeto VkSurfaceKHR precisa ser criado logo após a criação da instância, porque ele pode influenciar a seleção do dispositivo físico.

VkSwapchainKHR

A partir do objeto VkSurfaceKHR podemos criar um objeto VkSwapchainKHR, que representa um swap chain. Esse objeto é essencialmente uma fila de imagens que estão esperando para serem apresentadas na tela. Nossa aplicação irá adquirir uma imagem para desenhar e, em seguida, retorná-la à fila. Como exatamente a fila funciona e as condições para apresentar uma imagem da fila dependem de como o objeto VkSwapchainKHR é configurado, mas a finalidade geral do swap chain é sincronizar a apresentação das imagens com a taxa de atualização da tela. Esse objeto requer um VkDevice. Os objetos VkImage utilizados por ele são automaticamente alocados, ou seja, não há necessidade de vincular um VkDeviceMemory para cada um deles.

VkImageView, VkBufferView

Para usar qualquer objeto VkImage, incluindo aqueles no swap chain, no pipeline gráfico, temos que criar um objeto VkImageView. Esse objeto descreve como acessar a imagem e qual parte da imagem acessar, por exemplo, se ela deve ser tratada como uma textura 2D que serve como uma textura de profundidade sem nenhum nível de mipmapping. Da mesma forma, o objeto VkBufferView é um objeto criado com base em um buffer específico. Podemos fornecer o offset (deslocamento) e o range (intervalo) durante a criação para limitar a exibição a apenas um subconjunto de dados do buffer.

VkDescriptorSetLayout

Em computação gráfica moderna, a maior parte da renderização e processamento de dados de imagem (como vértices, pixels ou fragmentos) é feita com pipelines e shaders programáveis. Shaders, para operar adequadamente e gerar resultados apropriados, precisam acessar fontes de dados adicionais, como texturas, samplers, buffers ou variáveis do tipo uniform. No Vulkan, elas são fornecidas por meio de conjuntos de descritores.

Os descritores são estruturas de dados opacas que representam recursos do shader. Para fornecer recursos aos shaders, ligamos os conjuntos de descritores aos pipelines. Podemos ligar vários conjuntos de uma só vez. Para acessar recursos de dentro dos shaders, precisamos especificar de qual conjunto e de qual local dentro de um conjunto (chamado de binding) o recurso dado é adquirido. Mas antes de criar um conjunto de descritores, seu layout deve ser especificado criando um objeto VkDescriptorSetLayout. Esse objeto representa o tipo de informação que um conjunto de descritores contém. Essencialmente, ele descreve quais bindings estão no conjunto de descritor para cada estágio do shader. Por exemplo, podemos definir no binding 0 um buffer constante usado por ambos os estágios do vertex shader e fragment shader, no binding 1 um buffer de armazenamento e no 2 uma imagem apenas para o estágio do fragment shader.

VkDescriptorPool

De forma análoga aos buffers de comando, os conjuntos de descritores não podem ser criados diretamente, eles devem ser alocados a partir de um objeto VkDescriptorPool. Ao criar um pool de descritores, precisamos especificar o número máximo de conjuntos de descritores e os diferentes tipos de descritores que serão alocados a partir dele.

VkDescritproSet

A partir de um objeto VkDescriptorPool e de um objeto VkDescriptorSetLayout podemos alocar um objeto VkDescriptorSet. Este é o objeto que usaremos para vincular os recursos, portanto ele apontará para os objetos VkBuffer, VkBufferView, VkImageView ou VkSampler específicos. Podemos fazer isso usando a função vkUpdateDescriptorSets.

Vários objetos do tipo VkDescriptorSet podem ser ligados como conjuntos ativos em um objeto VkCommandBuffer para serem usados pelos comandos de renderização. Para fazer isso, usamos a função vkCmdBindDescriptorSets. Essa função também requer outro objeto: VkPipelineLayout.

VkPipelineLayout

O acesso aos conjuntos de descritores de um pipeline é realizado por meio de um objeto VkPipelineLayout. Vários objetos do tipo VkDescriptorSetLayout podem ser combinados para formar um objeto VkPipelineLayout que descreve o conjunto completo de recursos que podem ser acessados pelo pipeline. O objeto VkPipelineLayout representa uma sequência de conjuntos de descritores, cada um com um layout específico. Essa sequência de layouts é usada para determinar a interface entre os estágios do shader e os recursos do shader. Cada pipeline é criado usando um VkPipelineLayout.

VkRenderpass

Uma grande diferença entre o Vulkan e outras APIs (exceto para o Metal) é que o Vulkan agrupa comandos de renderização em render passes. Um render pass é uma coleção de subpasses que descreve como os recursos de imagem (cor, profundidade/estêncil e anexos de entrada) são usados: quais são seus layouts e como esses layouts devem ser transicionados entre subpasses, quando renderizamos em anexos4 ou quando lemos dados deles, se seus conteúdos são necessários depois do render pass, ou seu uso é limitado apenas ao escopo de um render pass.

4 Anexo é o nome do Vulkan para uma imagem a ser usada como saída da renderização.

No Vulkan, um render pass é descrito por um objeto VkRenderPass. Isso fornece um template que é usado ao iniciar um render pass dentro de um buffer de comando.

As operações realizadas em um render pass são agrupadas em subpasses5. Cada subpass representa um estágio ou uma fase de nossos comandos de renderização, nos quais um subconjunto de anexos do render pass é usado (no qual renderizamos ou do qual lemos dados).

5 Um render pass sempre requer pelo menos um subpass que é iniciado automaticamente quando iniciamos um render pass. E para cada subpass, precisamos preparar uma descrição.

VkFramebuffer

Mas para que as operações de desenho sejam realizadas corretamente, só o render pass não é suficiente, pois ele apenas especifica como as operações são ordenadas em subpasses e como os anexos são usados. Não há informações sobre quais imagens são usadas para esses anexos. Essas informações sobre recursos específicos usados para todos os anexos definidos são armazenadas em framebuffers.

Os anexos especificados durante a criação do render pass são vinculados ao agrupá-los em um objeto VkFramebuffer. Esse objeto faz referência a todos os objetos do tipo VkImageView que representam os anexos. Sempre que começamos a renderização de um VkRenderPass, devemos chamar a função vkCmdBeginRenderPass e também passar o objeto VkFramebuffer para ela.

VkPipeline

Operações registradas em buffers de comando e submetidas a filas são processadas pelo hardware. O processamento é executado em uma série de etapas que formam um pipeline. No Vulkan, quando queremos realizar cálculos matemáticos, usamos um pipeline de computação. Se quisermos desenhar algo, precisamos de um pipeline gráfico.

Os objetos do tipo VkPipeline controlam o modo como os cálculos são executados ou a geometria é desenhada. Eles gerenciam o comportamento do hardware no qual nossa aplicação é executada. Objetos VkPipeline são uma das maiores e mais aparentes diferenças entre Vulkan e OpenGL. O OpenGL usa uma máquina de estado que nos permite alterar muitos parâmetros de computação ou renderização sempre que queremos. Podemos configurar o estado, ativar um programa de shader, desenhar uma geometria, depois ativar outro programa de shader e desenhar outra geometria. No Vulkan isso não é possível porque todo o estado de computação ou processamento é armazenado em um único objeto imutável. Para cada conjunto diferente de parâmetros necessários durante a renderização, devemos criar um novo objeto VkPipeline. Em seguida, podemos defini-lo como o pipeline ativo atual em um objeto do tipo VkCommandBuffer chamando a função vkCmdBindPipeline.

VkShaderModule

Tradicionalmente, as APIs gráficas eram capazes de ler programas de shader escritos em uma linguagem de alto nível, como GLSL (no OpenGL) e HLSL (no Direct3D). No entanto, o Vulkan requer que os programas de shader sejam convertidos em um formato bytecode (binário). Este formato é chamado SPIR-V e foi projetado para ser usado tanto com o Vulkan quanto com o OpenCL.

No Vulkan, um buffer preenchido com dados no formato SPIR-V é usado para criar um objeto VkShaderModule. Esse objeto é apenas um envoltório fino ao redor do bytecode do shader. A compilação e vinculação do bytecode do SPIR-V ao código de máquina para execução pela GPU não acontece até que o pipeline gráfico seja criado.

VkPipelineCache

Criar um objeto VkPipeline é um processo caro e demorado do ponto de vista do driver. Um objeto VkPipeline não é um simples contêiner para parâmetros definidos durante a criação. Isso envolve a preparação dos estados de todos os estágios programáveis e fixos do pipeline, definir uma interface entre os shaders e recursos do descritor, compilar e vincular programas de shader e executar a verificação de erros (ou seja, verificar se os shaders estão vinculados adequadamente). Por isso, o Vulkan permite que os resultados dessas operações sejam armazenados em cache através de um objeto do tipo VkPipelineCache. Esse objeto pode ser reutilizado para acelerar a criação de objetos do tipo VkPipeline com propriedades semelhantes.

Como o leitor pode notar, existem muitos objetos, mas o propósito de cada um se tornará mais fácil de entender nos próximos capítulos. Se o leitor estiver confuso sobre a relação de um único objeto comparado a todo o programa, sugerimos que consulte novamente esta seção.


Anterior Próximo