Link

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 VkShaderModuleCreateInfo5. 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.


Anterior Próximo