Shaders
A criação de um pipeline gráfico requer que preparemos muitos dados na forma de estruturas ou até mesmo arrays de estruturas. O primeiro desses dados é uma coleção de todos os estágios de shader que serão usados durante a renderização com um determinado pipeline gráfico.
Shaders são pequenos programas executados dentro de GPUs. No OpenGL, escrevemos shaders no formato GLSL. Eles são compilados e, em seguida, vinculados a programas de shader diretamente em nosso aplicativo. Vulkan, por outro lado, aceita apenas uma representação binária de shaders, uma linguagem intermediária chamada SPIR-V. Não podemos fornecer código GLSL como faríamos no OpenGL. Mas existe um compilador oficial separado que pode transformar shaders escritos em GLSL em um formato bytecode do SPIR-V. Para usá-lo, temos que fazê-lo offline4. Depois de prepararmos o shader no formato SPIR-V, podemos criar um módulo shader a partir dele. Esses módulos são então compostos em um array de estruturas do tipo VkPipelineShaderStageCreateInfo
, que são usados, entre outros parâmetros, para criar o pipeline gráfico.
4 Também existe a possibilidade de compilar shaders no formato GLSL para SPIR-V no tempo de execução do aplicativo, mas para isso é necessário o uso de bibliotecas externas como glslang e shaderc.
Compilando shaders GLSL em SPIR-V
Criaremos um diretório chamado shaders
no diretório raiz do nosso projeto e armazenaremos o vertex shader em um arquivo chamado shader.vert
e o fragment shader em um arquivo chamado shader.frag
.
Iremos assumir que o leitor tenha um conhecimento básico em GLSL. Por isso, não iremos explicar detalhadamente o que cada programa de shader faz. Em vez disso vamos simplesmente apresentar seu código.
O conteúdo de shader.vert
deve ser o seguinte:
#version 450
layout(location = 0) out vec3 fragColor;
vec2 positions[3] = vec2[](
vec2(0.0, -0.5),
vec2(0.5, 0.5),
vec2(-0.5, 0.5)
);
vec3 colors[3] = vec3[](
vec3(1.0, 0.0, 0.0),
vec3(0.0, 1.0, 0.0),
vec3(0.0, 0.0, 1.0)
);
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
fragColor = colors[gl_VertexIndex];
}
Codificamos as posições e cores de todos os vértices usados para renderizar o triângulo. Eles são indexados usando a variável interna gl_VertexIndex
específica do Vulkan.
O conteúdo de shader.frag
deve ser o seguinte:
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(fragColor, 1.0);
}
Como mencionado anteriormente, o código dos módulos do shader é lido a partir de arquivos que contêm o conjunto binário SPIR-V. Esses arquivos podem ser gerados com um aplicativo chamado glslangValidator
. Esta é uma ferramenta distribuída oficialmente com o SDK Vulkan e foi projetada para validar os shaders GLSL. Vamos compilar os shaders em bytecode do SPIR-V usando o glslangValidator
. Para isso, adicionamos o seguinte código ao final do arquivo .pro
do nosso projeto:
Shaders = shaders/shader.vert shaders/shader.frag
for (shader, Shaders) {
exists($$_PRO_FILE_PWD_/$${shader}) {
message(Compiling Spir-V $$_PRO_FILE_PWD_/$${shader})
ERROR = $$system(glslangValidator -V -o $$_PRO_FILE_PWD_/$${shader}.spv $$_PRO_FILE_PWD_/$${shader})
message($$ERROR)
}
}
Em seguida, salvamos o arquivo myqtvkproject.pro
clicando em File → Save All e verificamos se os arquivos shader.vert.spv
e shader.frag.spv
foram criados na pasta shaders. Se aparecer a mensagem glslangValidator: command not found
em General Messages significa que o Qt não encontrou o executável glslangValidator
. Isso significa que a variável de ambiente PATH
não foi configurada corretamente com o caminho para a pasta bin
do SDK Vulkan.
Criando arquivos de recursos
O Qt fornece um mecanismo chamado Resource System, que armazena arquivos binários e de texto externos no executável do aplicativo, encapsulando o aplicativo e seus arquivos de recursos em um único arquivo binário. Essa incorporação de arquivos de recursos é feita durante o processo de criação. Em nosso aplicativo, usaremos esse mecanismo para armazenar os shaders no formato SPIR-V.
Para usar o Resource System, primeiro adicionamos ao nosso projeto Qt um arquivo de coleção de recursos. Este é um arquivo XML, com extensão .qrc
, que lista os arquivos de recursos a serem incorporados. Não precisamos editar esse arquivo XML diretamente porque o Qt Creator fornece uma interface gráfica, o Resouce Editor, para gerenciar recursos.
No Qt Creator, primeiro selecionamos File → New File or Project, Qt na lista da esquerda e Qt Resource na lista central. Clique no botão Choose… e definimos o campo Name como “resources”. Depois clicamos em Next e Finish.
Agora precisamos adicionar os shaders aos recursos. Para isso, devemos clicar com o botão direito no arquivo resources.qrc
no Qt Creator e selecionar Add Existing Files….
Selecionamos os arquivos shader.vert.spv
e shader.frag.spv
que estão na pasta shaders
. Com isso estamos prontos para carregar os shaders em nosso programa.
Carregando um shader
Agora que temos uma maneira de produzir shaders no formato SPIR-V, é hora de carregá-los em nosso programa para depois ligá-los ao pipeline gráfico. Primeiro vamos escrever uma função auxiliar simples para carregar os dados binários dos arquivos. Em render.h
declaramos a seguinte função:
...
private:
static QByteArray readFile(const QString &fileName);
}
Em render.cpp
inserimos:
#include <QFile>
...
QByteArray Renderer::readFile(const QString &fileName) {
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly))
QDebug(QtFatalMsg) << QLatin1String("Failed to open file:") << fileName;
QByteArray content = file.readAll();
file.close();
return content;
}
A função readFile
lerá todos os bytes do arquivo especificado e os retornará em um array de bytes gerenciado por QByteArray
.
Agora chamaremos essa função em initPipeline
para carregar o bytecode dos dois shaders
:
void Renderer::initPipeline() {
QByteArray vertShaderCode = readFile(":shaders/shader.vert.spv");
QByteArray fragShaderCode = readFile(":shaders/shader.frag.spv");
}
Criando módulos shader Vulkan
Antes de podermos passar o código para o pipeline, temos que envolvê-lo em um objeto VkShaderModule
. Vamos criar uma função auxiliar createShaderModule
na classe Renderer
para fazer isso.
#include <QVulkanFunctions>
...
VkShaderModule Renderer::createShaderModule(const QByteArray &code) {
}
A função receberá um buffer com o bytecode como parâmetro e criará um objeto VkShaderModule
a partir dele.
Para criar um módulo shader só precisamos especificar um ponteiro para o buffer com o bytecode e o comprimento dele. Essas informações são especificadas em uma estrutura VkShaderModuleCreateInfo
5. O problema é que o tamanho do bytecode é especificado em bytes, mas o ponteiro do bytecode é um ponteiro uint32_t
em vez de um ponteiro char
. Portanto, precisaremos converter o ponteiro com reinterpret_cast
, conforme mostrado abaixo. Quando executamos um cast como este, também precisamos garantir que os dados satisfaçam os requisitos de alinhamento do uint32_t
. Mas como os dados estão sendo armazenados em um objeto QByteArray
, seu alocador padrão já garante que os dados satisfaçam os requisitos de alinhamento.
VkShaderModuleCreateInfo createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = size_t(code.size());
createInfo.pCode = reinterpret_cast<const uint32_t *>(code.constData());
5 Como com muitas funções de criação do Vulkan, a maioria dos parâmetros é passada por uma estrutura de criação. Essa abordagem torna mais eficiente a criação de vários objetos idênticos e fornece uma maneira de oferecer suporte a parâmetros adicionais seguros para tipos por meio de extensões.
O objeto VkShaderModule
pode então ser criado com uma chamada para vkCreateShaderModule
:
VkShaderModule shaderModule;
VkDevice device = m_window->device();
VkResult result = m_deviceFunctions->vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule);
if (result != VK_SUCCESS)
QDebug(QtFatalMsg) << QLatin1String("Failed to create shader module:") << result;
Os parâmetros são: o dispositivo lógico, o ponteiro para a estrutura de informação de criação, o ponteiro opcional para os alocadores personalizados e a variável de saída do identificador. Por último, a função deve retornar o módulo shader criado:
return shaderModule;
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. Isso significa que só estamos autorizados a destruir os módulos shaders assim que a criação do pipeline for concluída, e é por isso que faremos as variáveis na função initPipeline
locais em vez de membros da classe:
void Renderer::initPipeline() {
VkDevice device = m_window->device();
QByteArray vertShaderCode = readFile(":shaders/shader.vert.spv");
QByteArray fragShaderCode = readFile(":shaders/shader.frag.spv");
VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);
A limpeza deve acontecer no final da função, adicionando duas chamadas para vkDestroyShaderModule
:
...
m_deviceFunctions->vkDestroyShaderModule(device, fragShaderModule, nullptr);
m_deviceFunctions->vkDestroyShaderModule(device, vertShaderModule, nullptr);
}
Os parâmetros para a função vkDestroyShaderModule
são diretos. O último parâmetro é opcional e permite especificar retornos de chamada para um alocador de memória personalizado. Vamos ignorar este parâmetro e sempre passar nullptr
como argumento. Todo o código restante nesta seção será inserido antes dessas linhas.
Criação do shader stage
Para realmente usar os shaders, precisaremos atribuí-los a um estágio do pipeline específico por meio da estrutura VkPipelineShaderStageCreateInfo
como parte do processo de criação do pipeline.
Começaremos preenchendo a estrutura para o vertex shader na função initPipeline
.
VkPipelineShaderStageCreateInfo vertShaderStageInfo = {};
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
O primeiro passo, além do obrigatório membro sType
, é dizer ao Vulkan em qual estágio do pipeline o shader será usado. Há um valor enum
para cada um dos estágios programáveis descritos anteriormente.
vertShaderStageInfo.module = vertShaderModule;
vertShaderStageInfo.pName = "main";
Os próximos dois membros especificam o módulo shader que contém o código e a função a ser chamada, conhecida como entrypoint (ponto de entrada). Isso significa que é possível combinar vários vertex shaders em um único módulo shader e usar diferentes pontos de entrada para diferenciar seus comportamentos. Neste caso, vamos nos ater ao padrão main
.
Modificar a estrutura para se adequar ao fragment shader é simples:
VkPipelineShaderStageCreateInfo fragShaderStageInfo = {};
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragShaderStageInfo.module = fragShaderModule;
fragShaderStageInfo.pName = "main";
Concluímos definindo um array que contém essas duas estruturas, as quais usaremos posteriormente para referenciá-las na etapa de criação do pipeline.
VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};
Isso é tudo para descrever os estágios programáveis do pipeline que usaremos neste projeto.