Link

Abstraindo a criação de buffer

Os buffers são os recursos mais simples porque representam dados que podem ser dispostos na memória apenas linearmente, como nos arrays típicos do C/C++. Eles podem ser usados para vários fins. Podem ser usados em pipelines por meio de conjuntos de descritores para armazenar dados em uniform buffers, buffers de armazenamento ou buffers de texel5, entre outros. Eles podem ser uma fonte de dados para índices ou atributos de vértices, ou podem ser usados como recursos intermediários para transferência de dados da CPU para a GPU. Para todos esses efeitos, precisamos apenas criar um buffer e especificar seu uso.

5 Um texel é um elemento de textura, essencialmente é um pixel dentro de um objeto de imagem.

Como vamos criar vários buffers neste e nos próximos capítulos, é uma boa ideia criar uma função auxiliar para isso. Criamos uma nova função createBuffer na classe Renderer:

void Renderer::createBuffer(VkDeviceSize size,
                            VkBufferUsageFlags usage,
                            VkMemoryPropertyFlags properties,
                            VkBuffer& buffer,
                            VkDeviceMemory& bufferMemory) {

}

No Vulkan, a criação de buffer e imagem consiste em pelo menos dois estágios. Primeiro, criamos o próprio objeto. Em seguida, precisamos criar um objeto de memória, que será vinculado ao buffer (ou imagem). Nesse objeto de memória, o buffer ocupará seu espaço de armazenamento. Essa abordagem nos permite especificar parâmetros adicionais para a memória e controlá-la com mais detalhes.

Para a criação de um buffer, Vulkan exige que preenchemos uma estrutura VkBufferCreateInfo.

VkBufferCreateInfo bufferInfo = {};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = size;

O campo size dessa estrutura especifica o tamanho em bytes do buffer.

bufferInfo.usage = usage;

O campo usage informa ao Vulkan como vamos usar o buffer e é um campo de bit composto por uma combinação de membros da enumeração VkBufferUsageFlagBits. Por exemplo, podemos especificar que queremos usar o buffer como um buffer de vértice (VK_BUFFER_USAGE_VERTEX_BUFFER_BIT), buffer de índice (VK_BUFFER_USAGE_INDEX_BUFFER_BIT), fonte de dados para operações de transferência (VK_BUFFER_USAGE_TRANSFER_SRC_BIT) e assim por diante. Todas as maneiras em que o buffer será usado em nosso aplicativo devem ser especificadas no campo usage. Não podemos usar um buffer de uma maneira que não tenha sido definida durante a criação do buffer.

bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
bufferInfo.queueFamilyIndexCount = 0;
bufferInfo.pQueueFamilyIndices = nullptr;

O campo sharingMode indica como o buffer será usado nas várias filas de comando suportadas pelo dispositivo. Como o Vulkan pode executar muitas operações em paralelo, algumas implementações precisam saber se o buffer será essencialmente usado por um único comando por vez ou potencialmente por muitos. Definir sharingMode como VK_SHARING_MODE_EXCLUSIVE diz que o buffer será usado apenas em uma única fila, enquanto que definir sharingMode como VK_SHARING_MODE_CONCURRENT indica que planejamos usar o buffer em várias filas ao mesmo tempo. Usar VK_SHARING_MODE_CONCURRENT pode resultar em desempenho inferior em alguns sistemas, portanto, a menos que precisemos disso, configuramos sharingMode como VK_SHARING_MODE_EXCLUSIVE.

Se definirmos sharingMode como VK_SHARING_MODE_CONCURRENT, precisaremos informar ao Vulkan em quais filas usaremos o buffer. Isso é feito usando o campo pQueueFamilyIndices, que é um ponteiro para um array de famílias de filas na qual o recurso será usado. queueFamilyIndexCount contém o tamanho desse array – o número de famílias de filas com as quais o buffer será usado. Quando sharingMode é definido como VK_SHARING_MODE_EXCLUSIVE, queueFamilyCount e pQueueFamilies são ignorados.

Como os buffers que utilizaremos neste projeto só serão usados a partir da fila de gráficos, então podemos nos ater ao acesso exclusivo (VK_SHARING_MODE_EXCLUSIVE).

Agora que fornecemos valores para todos os parâmetros, podemos criar um buffer. Isso é feito através da função vkCreateBuffer para a qual precisamos fornecer um identificador de um dispositivo lógico, um ponteiro para a estrutura descrita acima e um ponteiro para uma variável do tipo VkBuffer na qual o identificador do buffer criado será armazenado:

VkDevice device = m_window->device();
VkResult result = m_deviceFunctions->vkCreateBuffer(
		device,
		&bufferInfo,
		nullptr,
		&buffer
	);
if (result != VK_SUCCESS) {
	qFatal("Failed to create vertex buffer: %d", result);
}

Requisitos de memória

Como mencionado anteriormente, inicialmente os buffers não estão associados a nenhum tipo de memória. O aplicativo deve alocar e vincular a memória do dispositivo apropriada ao buffer antes que ele possa ser usado. Mas para isso, primeiro devemos verificar quais são os requisitos de memória para o buffer criado. Fazemos isso chamando a função vkGetBufferMemoryRequirements. Essa função armazena parâmetros para criação de memória em uma variável que fornecemos o endereço no último parâmetro. Essa variável deve ser do tipo VkMemoryRequirements:

VkMemoryRequirements memRequirements;
m_deviceFunctions->vkGetBufferMemoryRequirements(
		device,
		buffer,
		&memRequirements
	);

VkMemoryRequirements possui três campos:

  • size é o tamanho, em bytes, da alocação de memória necessária para o buffer, pode diferir de bufferInfo.size.
  • alignment é o alinhamento, em bytes, do deslocamento dentro da alocação necessária para o buffer, depende de bufferInfo.usage.
  • memoryTypeBits é um campo de bits e contém um conjunto de bits para cada tipo de memória suportada para o buffer. O bit i é definido se, e somente se, o tipo de memória i na estrutura VkPhysicalDeviceMemoryProperties do dispositivo físico for suportado para o recurso.

Cada dispositivo pode ter e expor diferentes tipos de memória – heaps de vários tamanhos com propriedades diferentes. Um tipo de memória pode ser a memória local de um dispositivo localizada nos chips GDDR (portanto, muito rápida). Outra pode ser uma memória compartilhada visível tanto pela GPU quanto pela CPU. Tanto a GPU quanto o aplicativo podem ter acesso a essa memória, mas esse tipo de memória é mais lento do que a memória local do dispositivo (que é acessível apenas pela GPU). Precisamos combinar os requisitos do buffer e nossos próprios requisitos de aplicativo para encontrar o tipo correto de memória a ser usado. Vamos criar uma nova função findMemoryType para essa finalidade.

uint32_t Renderer::findMemoryType(
		uint32_t typeFilter,
		VkMemoryPropertyFlags properties) {

}

Para verificar quais heaps de memória e tipos estão disponíveis, precisamos chamar a função vkGetPhysicalDeviceMemoryProperties, que armazena informações sobre memória em uma estrutura VkPhysicalDeviceMemoryProperties:

VkPhysicalDeviceMemoryProperties memProperties;
QVulkanInstance *inst = m_window->vulkanInstance();
QVulkanFunctions *f = inst->functions();

f->vkGetPhysicalDeviceMemoryProperties(
		m_window->physicalDevice(),
		&memProperties
	);

Como vkGetPhysicalDeviceMemoryProperties não é uma função de nível de dispositivo precisamos de um objeto QVulkanFunctions recuperável via QVulkanInstance::functions() para poder acessá-la.

A estrutura VkPhysicalDeviceMemoryProperties contém as seguintes informações:

  • memoryHeapCount: Número de heaps de memória expostos por um determinado dispositivo.
  • memoryHeaps: Um array de heaps de memória. Cada heap representa uma memória de tamanho e propriedades diferentes.
  • memoryTypeCount: Número de diferentes tipos de memória expostos por um determinado dispositivo.
  • memoryTypes: Um array de tipos de memória. Cada elemento descreve propriedades de memória específicas e contém um índice de uma heap que possui essas propriedades específicas.

Vamos primeiro encontrar um tipo de memória que seja adequado para o buffer em si:

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
	if ((typeFilter & (1 << i)) {
		return i;
	}
}

qFatal("Failed to find suitable memory type!");

O parâmetro typeFilter será usado para especificar o campo de bits dos tipos de memória adequados. Isso significa que podemos encontrar o índice de um tipo de memória adequado simplesmente fazendo uma iteração sobre eles e verificando se o bit correspondente está definido como 1.

No entanto, não estamos interessados apenas em um tipo de memória adequado para o buffer em si. Nós também precisamos verificar se um determinado tipo de memória suporta nossas propriedades adicionais solicitadas, por exemplo, se um determinado tipo de memória é visível para o host. Para isso, podemos modificar o laço for para também verificar o suporte dessas propriedades:

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
	 if ((typeFilter & (1 << i)) &&
	 	(memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
		return i;
	}
}

qFatal("Failed to find suitable memory type!");

Como podemos ter mais de uma propriedade desejável, devemos verificar se o resultado da operação AND bit a bit não é apenas diferente de zero, mas igual ao campo de bits de propriedades desejado. Se houver um tipo de memória adequado para o buffer que também tenha todas as propriedades que precisamos, então retornamos seu índice, caso contrário, usamos a macro qFatal.

Alocação de memória

Agora que temos uma maneira de determinar o tipo de memória correto, podemos realmente alocar a memória preenchendo a estrutura VkMemoryAllocateInfo em createBuffer.

VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);

O preenchimento dessa estrutura é simples. Especificamos o tamanho da memória no campo allocationSize e o tipo no campo memoryTypeIndex, ambos derivados dos requisitos de memória do buffer e da propriedade desejada.

A memória é alocada usando a função vkAllocateMemory:

result = m_deviceFunctions->vkAllocateMemory(
		device,
		&allocInfo,
		nullptr,
		&bufferMemory
	);
if ( result != VK_SUCCESS) {
	qFatal("Failed to allocate vertex buffer memory!");
}

Temos os requisitos de memória que nos ajudaram a obter o tipo certo de memória; usando isso, alocamos a memória. Agora podemos ligar o objeto de recurso a esta memória alocada usando vkBindBufferMemory:

m_deviceFunctions->vkBindBufferMemory(
		device,
		buffer,
		bufferMemory,
		0
	);

O último parâmetro especifica o deslocamento de memória dentro do objeto de memória. Isso nos permite ligar uma parte da memória que não está no início do objeto de memória. Podemos usar o parâmetro de deslocamento para vincular várias partes separadas de um único objeto VkDeviceMemory a vários buffers. Mas como essa memória é alocada exclusivamente para esse novo buffer, o deslocamento é simplesmente 0.

Deve-se notar que em um aplicativo do mundo real, não devemos realmente chamar vkAllocateMemory para cada buffer individual. Em vez disso, devemos alocar blocos de memória maiores e atribuir partes deles aos objetos de buffer 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 pode ser tão baixo quanto 4096 (no Windows), mesmo em hardware de ponta, como uma NVIDIA RTX 2080. No entanto, para este projeto não há problema em usar uma alocação separada para cada buffer, porque não chegaremos perto de atingir nenhum desses limites por enquanto.


Anterior Próximo