Link

Criação do buffer de vértices

Agora vamos por em uso a função createBuffer para criarmos o buffer de vértice. Para isso, poderíamos criar apenas um buffer com a propriedade VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT e mapear diretamente a memória do buffer na memória acessível da CPU com vkMapMemory. O problema dessa abordagem é que esse tipo de memória que nos permite acessá-la da CPU pode não ser o tipo de memória mais ideal para a leitura da própria placa gráfica. A memória mais ideal tem o sinalizador VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT e geralmente não é acessível pela CPU em placas gráficas dedicadas. Por isso, vamos criar dois buffers de vértices. Um staging buffer (buffer de preparação) na memória acessível pela CPU para carregar os dados do array de vértices, e um buffer de vértices final na memória local do dispositivo. Em seguida, usaremos um comando de cópia de buffer para mover os dados do staging buffer para o buffer de vértice real.

Criamos uma nova função createObjectVertexBuffer na classe Renderer e chamamos-a em initObject.

void Renderer::initObject() {
    QSharedPointer<Model> model = QSharedPointer<Model>::create(Model());
    m_object = new Object3D(model);

   createObjectVertexBuffer();
}

void Renderer::createObjectVertexBuffer() {

}

Usando um staging buffer

Vamos agora criar um buffer na memória visível do host para que possamos usar o vkMapMemory e copiar os vértices para ele. Para isso, adicionamos variáveis para esse buffer temporário na a função createObjectVertexBuffer:

VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;

Em seguida, calculamos o tamanho em bytes dos dados dos vértices com sizeof:

VkDeviceSize bufferSize = sizeof(m_object->model->vertices[0]) * m_object->model->vertices.size();

O buffer deve estar na memória visível do host para que possamos mapeá-lo e ele deve ser usado como uma fonte de transferência para que possamos copiá-lo para um buffer mais tarde:

createBuffer(bufferSize,
	VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
	VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
	stagingBuffer,
	stagingBufferMemory);

Usamos o sinalizador VK_BUFFER_USAGE_TRANSFER_SRC_BIT para indicar que o buffer pode ser usado como fonte em uma operação de transferência de memória, e a propriedade VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT que permite mapear a memória para que possamos escrever nela a partir da CPU. Também precisamos usar a propriedade VK_MEMORY_PROPERTY_HOST_COHERENT_BIT. Já veremos o porquê.

Agora é hora de copiar os dados dos vértices para o buffer. Isso é feito mapeando a memória do buffer na memória acessível da CPU com vkMapMemory:

void* data;
VkDevice device = m_window->device();
m_deviceFunctions->vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);

Essa função nos permite acessar uma região do recurso de memória especificado definido por um deslocamento e tamanho. O deslocamento e tamanho aqui são 0 e bufferSize, respectivamente. Também é possível especificar o valor especial VK_WHOLE_SIZE para mapear toda a memória. O penúltimo parâmetro pode ser usado para sinalizadores específicos, mas ainda não há nenhum disponível na API atual. Isso deve ser definido para o valor 0. O último parâmetro especifica a saída do ponteiro para a memória mapeada.

void* data;
VkDevice device = m_window->device();
m_deviceFunctions->vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
memcpy(data, m_object->model->vertices.data(), (size_t) bufferSize);
m_deviceFunctions->vkUnmapMemory(device, stagingBufferMemory);

Agora podemos simplesmente copiar os dados de vértice para a memória mapeada usando memcpy e desmapear novamente usando vkUnmapMemory. Infelizmente, o driver não pode copiar imediatamente os dados para a memória do buffer, por exemplo, devido ao armazenamento em cache. Também é possível que as gravações no buffer ainda não estejam visíveis na memória mapeada. Existem duas maneiras de lidar com esse problema:

  1. Usar um heap de memória que seja host coerente, indicado com VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
  2. Chamar vkFlushMappedMemoryRanges depois de gravar na memória mapeada e chamar vkInvalidateMappedMemoryRanges antes de ler a partir da memória mapeada

Nós optamos pela primeira abordagem, que garante que a memória mapeada corresponda sempre ao conteúdo da memória alocada. Isso pode levar a um desempenho um pouco pior do que o flushing explícito, mas veremos porque isso não importa mais a frente.

Agora podemos criar o buffer de vértice na memória do dispositivo. Definimos um membro de estrutura em Object3D para manter o identificador de buffer e chamamos-o de vertexBuffer, e outro membro de estrutura para armazenar o identificador para a memória e chamamos-o de vertexBufferMemory:

struct Object3D
{
    Object3D(QSharedPointer<Model> model);

    VkBuffer vertexBuffer = VK_NULL_HANDLE;
    VkDeviceMemory vertexBufferMemory = VK_NULL_HANDLE;

    QSharedPointer<Model> model;
};

Em seguida, chamamos a função createBuffer em createObjectVertexBuffer com os seguintes parâmetros:

createBuffer(
	bufferSize,
	VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
	VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
	m_object->vertexBuffer,
	m_object->vertexBufferMemory
);

O membro vertexBuffer é alocado de um tipo de memória que é local do dispositivo (VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), o que geralmente significa que não podemos usar vkMapMemory. No entanto, podemos copiar dados do stagingBuffer para o m_object->vertexBuffer. Temos que indicar que pretendemos fazer isso especificando o sinalizador de origem de transferência, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, para stagingBuffer e o sinalizador de destino de transferência, VK_BUFFER_USAGE_TRANSFER_DST_BIT, para m_object->vertexBuffer, juntamente com o sinalizador de uso de buffer de vértices, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT.

Vamos agora escrever uma outra função auxiliar para copiar o conteúdo de um buffer para outro, chamada copyBuffer.

void Renderer::copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {

}

As operações de transferência de memória são executadas usando buffers de comando. Portanto, devemos primeiro alocar um buffer de comando temporário. Se quisermos, podemos criar um pool de comandos separado para esses tipos de buffers de curta duração, pois a implementação pode aplicar otimizações de alocação de memória. Nesse caso, devemos usar o sinalizador VK_COMMAND_POOL_CREATE_TRANSIENT_BIT durante a geração do pool de comando.

Como operações de iniciar e terminar um buffer de comando também serão utilizadas em capítulos posteriores, vamos criar duas funções auxiliares para isso. A primeira função será chamada de beginSingleTimeCommands e retornará um objeto do tipo VkCommandBuffer:

VkCommandBuffer Renderer::beginSingleTimeCommands() {

}

Os buffers de comando são alocados com a função vkAllocateCommandBuffers, que usa uma estrutura VkCommandBufferAllocateInfo como parâmetro que especifica o pool de comandos e o número de buffers a serem alocados:

VkCommandBufferAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = m_window->graphicsCommandPool();
allocInfo.commandBufferCount = 1;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;

VkCommandBuffer commandBuffer;
VkDevice device = m_window->device();
m_deviceFunctions->vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);

Iremos utilizar o pool de comandos gráficos que já foi criado pela classe QVulkanWindow. Esse pool de comando pode ser recuperado através da função QVulkanWindow::graphicsCommandPool(). O parâmetro commandBufferCount especifica o número de buffers que serão alocados, neste caso, é apenas um. O parâmetro level especifica se os buffers de comando alocados são buffers de comando primário ou secundário.

  • VK_COMMAND_BUFFER_LEVEL_PRIMARY: Pode ser enviado para uma fila para execução, mas não pode ser chamado de outros buffers de comando.
  • VK_COMMAND_BUFFER_LEVEL_SECONDARY: Não pode ser enviado diretamente, mas pode ser chamado a partir de buffers de comando primários.

Não usaremos a funcionalidade de buffer de comando secundário aqui, mas, em algumas situações, pode-se imaginar que é útil reutilizar operações comuns dos buffers de comando primário.

Iniciando a gravação do buffer de comando

Começamos a gravar um buffer de comando chamando vkBeginCommandBuffer com uma pequena estrutura VkCommandBufferBeginInfo como argumento que especifica alguns detalhes sobre o uso desse buffer de comando específico.

VkCommandBufferBeginInfo beginInfo = {};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
beginInfo.pInheritanceInfo = nullptr;

m_deviceFunctions->vkBeginCommandBuffer(commandBuffer, &beginInfo);

O parâmetro flags especifica como vamos usar o buffer de comando. Os seguintes valores estão disponíveis:

  • VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT: Especifica que este é um buffer de comando secundário que estará inteiramente dentro de um único render pass.
  • VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT: Especifica que o buffer de comando pode ser reenviado enquanto ele também já está pendente de execução.
  • VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT: Especifica que o buffer de comando será regravado logo após ser executado uma vez.

Usamos o último valor porque cada gravação do buffer de comando será enviada somente uma vez, e o buffer de comando será redefinido e registrado novamente entre cada envio. O parâmetro pInheritanceInfo é relevante apenas para buffers de comando secundário. Ele especifica qual estado herdar dos buffers do comando principal de chamada.

return commandBuffer;

Por fim, retornamos o buffer de comando criado.

Terminando o buffer de comando

A segunda função será chamada de endSingleTimeCommands e como o nome já diz irá finalizar o buffer de comando:

void Renderer::endSingleTimeCommands(VkCommandBuffer commandBuffer) {
	m_deviceFunctions->vkEndCommandBuffer(commandBuffer);
}

O envio e a sincronização da fila são configurados por meio de parâmetros na estrutura VkSubmitInfo:

VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

Os parâmetros commandBufferCount e pCommandBuffers especificam, respectivamente, a quantidade e os buffers de comando que devem ser enviados para execução.

Agora podemos enviar o buffer de comando para a fila de gráficos usando vkQueueSubmit:

VkQueue graphicsQueue = m_window->graphicsQueue();
m_deviceFunctions->vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
m_deviceFunctions->vkQueueWaitIdle(graphicsQueue);

O primeiro parâmetro é a fila que queremos enviar o comando. Como mencionado anteriormente, iremos utilizar a fila de gráficos que já foi criada pela classe QVulkanWindow. O segundo parâmetro é um array de estruturas VkSubmitInfo que é usado para eficiência quando a carga de trabalho é muito maior. O último parâmetro faz referência a um objeto VkFence opcional que será sinalizado quando os buffers de comando terminarem a execução. Não há eventos que precisamos aguardar nesse momento, então vamos apenas passar VK_NULL_HANDLE. Nós apenas queremos executar a transferência nos buffers imediatamente. Há duas maneiras possíveis de esperar que essa transferência seja concluída. Poderíamos usar um objeto VkFence e esperar com vkWaitForFences, ou simplesmente esperar que a fila de transferência ficasse ociosa com vkQueueWaitIdle. Um objeto VkFence permitiria que agendássemos várias transferências simultaneamente e esperássemos todas concluírem, em vez de executá-las uma de cada vez. Isso pode fornecer ao driver mais oportunidades para otimizações. Aqui optamos por usar vkQueueWaitIdle.

VkDevice device = m_window->device();
VkCommandPool commandPool = m_window->graphicsCommandPool();
m_deviceFunctions->vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

Depois, limpamos o buffer de comando usado para a operação de transferência.

Agora podemos implementar a função copyBuffer usando essas duas funções:

void Renderer::copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
	VkCommandBuffer commandBuffer = beginSingleTimeCommands();

	VkBufferCopy copyRegion = {};
	copyRegion.srcOffset = 0;
	copyRegion.dstOffset = 0;
	copyRegion.size = size;
	m_deviceFunctions->vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

	endSingleTimeCommands(commandBuffer);
}

O conteúdo dos buffers é transferido usando o comando vkCmdCopyBuffer. Esse comando recebe os buffers de origem e destino como argumentos e um array de regiões para copiar. As regiões são definidas em estruturas VkBufferCopy e consistem em um deslocamento de buffer de origem (srcOffset), um deslocamento de buffer de destino (dstOffset) e um tamanho (size). Note que, ao contrário do comando vkMapMemory, no comando vkCmdCopyBuffer não é possível especificar VK_WHOLE_SIZE.

Copiando o staging buffer para o buffer do dispositivo

Agora podemos chamar copyBuffer a partir da função createObjectVertexBuffer para mover os dados de vértices para o buffer local do dispositivo:

createBuffer(
	bufferSize,
	VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
	VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
	m_object->vertexBuffer,
	m_object->vertexBufferMemory
);

copyBuffer(stagingBuffer, m_object->vertexBuffer, bufferSize);

Depois de copiar os dados do staging buffer para o buffer do dispositivo, devemos limpá-lo:

m_deviceFunctions->vkDestroyBuffer(
	device,
	stagingBuffer,
	nullptr
);
m_deviceFunctions->vkFreeMemory(
	device,
	stagingBufferMemory,
	nullptr
);

Para lidar com a liberação dos recursos associados ao objeto 3D, vamos criar uma nova função chamada releaseObjectResources. Utilizaremos essa função mais tarde quando formos recriar o objeto 3D6:

void Renderer::releaseObjectResources() {
	VkDevice device = m_window->device();

    if (m_object->vertexBuffer) {
        m_deviceFunctions->vkDestroyBuffer(
        	device,
        	m_object->vertexBuffer,
        	nullptr
        );
        m_object->vertexBuffer = VK_NULL_HANDLE;
    }
}

6 Não chamaremos essa função em releaseResources porque não queremos liberar os recursos do objeto 3D quando, por exemplo, a janela do programa não estiver mais ativa, ou seja, quando for minimizada ou outra janela (de outro programa) for selecionada.

A memória que está vinculada a um objeto de buffer pode ser liberada uma vez que o buffer não é mais usado, portanto, vamos liberá-la depois que o buffer foi destruído:

void Renderer::releaseObjectResources() {
	...
    if(m_object->vertexBufferMemory) {
        m_deviceFunctions->vkFreeMemory(
        	device,
        	m_object->vertexBufferMemory,
        	nullptr
        );
        m_object->vertexBufferMemory = VK_NULL_HANDLE;
    }
}

Nossos dados de vértice agora estão sendo carregados da memória de alto desempenho e isso será importante quando começarmos a renderizar uma geometria mais complexa.


Anterior Próximo