Vulkan教程 - 14 頂點緩衝

接下來幾章,我們會使用內存中的頂點緩衝替換掉頂點着色器中的硬編碼頂點數據。我們用最簡單的方式開始,創建一個CPU可見的緩衝,使用memcpy來將頂點數據直接拷貝到它上面,之後我們會介紹如何使用臨時緩衝來拷貝頂點數據到高性能內存中。

首先修改頂點着色器,不要再在着色器代碼中包括頂點數據。頂點着色器使用in關鍵字接收來自頂點緩衝的輸入:

#version 450
#extension GL_ARB_separate_shader_objects : enable

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;

layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = vec4(inPosition, 0.0, 1.0);
    fragColor = inColor;
}

inPosition和inColor變量是頂點屬性,它們是頂點緩衝中每個頂點都明確指定的,如同我們使用兩個數組手動指定的位置和顏色一樣。記得修改後重新編譯着色器。

和fragColor一樣,layout(location = x)標記對我們後來要用的輸入分配索引,這樣我們就能引用它們了。有些類型如dvec3 64位向量, 使用多個槽。這意味着它之後的索引至少要比它大2。

layout(location = 0) in dvec3 inPosition;
layout(location = 2) in vec3 inColor;

我們將頂點數據從頂點着色器中移動到了我們自己程序的數組中,包含GLM庫,它提供了線性代數有關的向量和矩陣。我們使用這些類型來指定位置和顏色向量。

#include <glm/glm.hpp>

創建一個叫做Vertex的結構體,裏面放兩個屬性,我們將會在頂點着色器中使用:

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;
};

GLM給我們提供了易於使用的C++類型,和着色器語言中的向量類型正好匹配:

const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
};

現在使用Vertex結構體來指定一組頂點數據。我們就使用和之前一樣的位置及顏色,但是現在它們被綁定到一組頂點上了。這也就是交叉頂點屬性。

下一步就是告訴Vulkan,一旦它被上傳到GPU內存,如何將這個數據格式傳遞到頂點着色器。爲了傳遞這個信息,需要兩類結構體。第一個是VkVertexInputBindingDescription,我們會在Vertex中添加一個成員方法,以讓它輸入正確的數據。

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;

    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDescription = {};

        return bindingDescription;
    }
};

頂點綁定描述以什麼樣的速率從內存加載數據。它指定了數據入口的字節個數,以及是否在每個頂點或每個實例後移動到下一個數據入口。

bindingDescription.binding = 0;
bindingDescription.stride = sizeof(Vertex);
bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

每個頂點的數據都是打包在一個數組中的,所以我們只要進行綁定即可。binding參數指定了綁定數組中綁定的索引,stride參數指定了內存中從一個記錄到下一個之間的字節個數。inputRate參數可以有以下值:

VK_VERTEX_INPUT_RATE_VERTEX:每個頂點處理後移動到下一個數據記錄;

VK_VERTEX_INPUT_RATE_INSTANCE:每個實例處理後移動到下一個數據記錄。

我們不會使用實例渲染,所以就還是用逐頂點數據。

處理頂點輸入的第二個結構體是VkVertexInputAttributeDescription。我們要添加另一個助手函數到Vertex:

static std::array<VkVertexInputAttributeDescription, 2>
    getAttributeDescriptions() {
    std::array<VkVertexInputAttributeDescription, 2>
        attributeDescriptions = {};

    return attributeDescriptions;
}

注意要包含array頭文件。

如函數原型所示,這裏將會有兩個這樣的結構體。一個屬性描述結構體描述如何從來自綁定描述的一堆頂點數據中提取一個頂點屬性。我們有兩個描述,位置和顏色,所以我們要兩個屬性描述結構體:

attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0].offset = offsetof(Vertex, pos);

binding參數告訴Vulkan逐頂點的數據來自哪個綁定。location參數引用頂點着色器中的輸入中的location。頂點着色器中的輸入有location 0的是位置,它由兩個32位浮點組件組成。

format參數描述了屬性的數據類型。有一點迷惑性的是,該格式使用的是和顏色格式一樣的枚舉。以下着色器類型和格式通常一起用:

float:VK_FORMAT_R32_SFLOAT;

vec2:VK_FORMAT_R32G32_SFLOAT;

vec3:VK_FORMAT_R32G32B32_SFLOAT;

vec4:VK_FORMAT_R32G32B32A32_SFLOAT。

可以看出,你要使用顏色通道個數與着色器數據類型組件個數匹配的格式。也能使用多於着色器中組件個數的通道,但是會靜默丟棄處理。如果通道個數比組件個數少,那麼BGA組件會使用默認值(0, 0, 1)。顏色類型(SFLOAT, UINT, SINT)和位寬也應該和着色器的輸入匹配,看下面的例子:

ivec2:VK_FORMAT_R32G32_SINT,這是一個由32位有符號整數組成的2組件向量;

uvec4:VK_FORMAT_R32G32B32A32_UINT,這是一個由32位無符號整數組成的4組件向量;

double:VK_FORMAT_R64_SFLOAT,雙精度浮點數(64位)。

format參數隱式定義了屬性數據的字節大小,offset參數指定了從逐頂點數據讀取的起始的字節數。綁定就是一次加載一個Vertex結構體數據,描述信息(pos)是一個值位0的相對於該結構體開頭的偏置。這個會使用offsetof宏自動計算。

attributeDescriptions[1].binding = 0;
attributeDescriptions[1].location = 1;
attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
attributeDescriptions[1].offset = offsetof(Vertex, color);

該顏色屬性描述也基本和位置的一樣。

現在我們要建立圖形管線來接收該格式的頂點數據,方法就是在createGraphicsPipeline中引用該結構體。找到vertexInputInfo結構體,修改如下來引用這兩個描述:

auto bindingDescription = Vertex::getBindingDescription();
auto attributeDescriptions = Vertex::getAttributeDescriptions();

VkPipelineVertexInputStateCreateInfo vertexInputInfo = {};
vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t>(attributeDescriptions.size());
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription;
vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data();

現在管線已經準備好接收vertices容器中格式的頂點數據了。現在運行程序,會發現沒用頂點緩衝綁定到該綁定:

下一步就是創建頂點緩衝並移動頂點數據到其中以便GPU能讀取。

Vulkan中的緩衝是一些內存區域,用於存儲顯卡能去讀的任意數據。它們可以用於存儲頂點數據,也就是我們這一章要做的事情。但是它們也可以用於許多其他目的,這等以後再看。不像我們之前處理的Vulkan對象,緩衝不會自動爲自己分配內存。

創建一個新的方法createVertexBuffer,從initVulkan中調用,就在createCommandBuffers之前。創建緩衝要填寫VkBufferCreateInfo:

VkBufferCreateInfo bufferInfo = {};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(vertices[0]) * vertices.size();

該結構體第一個字段是size,指定了緩衝大小,單位是字節。計算頂點數據大小很直白,用sizeof即可。

bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;

第二個字段是usage,表明了緩衝中的數據將用於什麼目的。使用按位與操作可以設定多個目標操作。我們這裏是一個頂點緩衝,以後看其他的用法。

bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

就和交換鏈中的圖像一樣,緩衝也可以被某個特定的隊列族擁有,或者同時在多個隊列族之間共享。這裏緩衝將會只用於圖形隊列,所以我們就還是用獨佔訪問模式。

flags參數用於配置稀疏緩衝內存,現在與我們不相干,就用默認值0。現在可以用vkCreateBuffer創建緩衝,定義一個類成員來保存緩衝句柄,就叫做vertexBuffer。

if (vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer) != VK_SUCCESS) {
    throw std::runtime_error("failed to create vertex buffer!");
}

緩衝會在渲染命令中可用,直到程序結束,且它不依賴交換鏈,所以就在cleanup方法中清理交換鏈操作之後清理掉它。

vkDestroyBuffer(device, vertexBuffer, nullptr);

現在緩衝創建好了,但是它實際上還沒有分配內存。給緩衝分配內存的第一步就是查詢它所需的內存量:

VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memRequirements);

該結構體有以下字段:

size:以字節爲單位的所需內存大小,可能和bufferInfo.size不一樣;

alignment:以字節爲單位的偏移量,緩衝起始於分配的內存區域,依賴於bufferInfo.usage和bufferInfo.flags;

memoryTypeBits:適用於緩衝的內存類型,這是一個位字段。

顯卡可以提供不同類型的內存分配。每種根據允許的操作變換,每個內存類型根據可用的操作變化。我們將緩衝的要求和我們的應用結合起來,以找到正確的內存來使用。爲我們創建一個新的方法findMemoryType:

uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) {

}

首先我們要查詢可用內存類型信息,用的方法是vkGetPhysicalDeviceMemoryProperties:

VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);

VkPhysicalDeviceMemoryProperties結構體有兩組memoryTypes和memoryHeaps。內存堆是截然不同的內存資源,如同專用VRAM以及RAM的交換空間一樣。這種不同類型的內存存在於這些堆之間。現在我們只關心這種內存而不關心它來自哪,但是你可以想象下這其實可以影響到性能的。

我們先找到適合該緩衝的內存類型:

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

throw std::runtime_error("failed to find suitable memory type!");

typeFilter參數用於指定合適的內存類型的位域。這表明我們可以找到一個合適內存類型的索引,方法就是遍歷它們,檢查是否對應位設置爲1。

但是我們僅關心適合於頂點緩衝的內存類型,我們要可以將我們的頂點數據寫入內存。memoryTypes數組由VkMemoryType結構體組成,指定了堆和每種內存的屬性。屬性定義了內存的特性,比如能進行映射以便我們可以從CPU向其寫入內容。這個屬性就是VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,但是我們還要用VK_MEMORY_PROPERTY_HOST_COHERENT_BIT屬性,當我們要映射內存的適合再看爲什麼需要。

我們可以修改循環,在其中檢查是否支持該特性:

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

我們可能有不止一個想要的屬性,所以我們應該檢查按位的或操作結果是否不僅僅非零,還要等於想要的屬性位域。如果有一個內存類型適合該緩衝,而且也有所有我們想要的特性,那麼我們應該返回它的索引,否則我們拋出異常。

我們現在可以確定正確的內存類型,所以我們可以分配內存了:

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

內存分配現在和指定大小及類型一樣簡單了,二者都是來自頂點緩衝的內存要求和想要的屬性。創建一個類成員來存儲處理內存和分配的句柄,

VkDeviceMemory vertexBufferMemory;
...
if (vkAllocateMemory(device, &allocInfo, nullptr, &vertexBufferMemory) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate vertex buffer memory!");
}

如果內存分配成功,現在我們可以使用vkBindBufferMemory來將該內存和緩衝聯繫到一起:

vkBindBufferMemory(device, vertexBuffer, vertexBufferMemory, 0);

開始的三個參數不用解釋,第四個是內存區域內的偏置。由於該內存是單獨爲該頂點緩衝分配的,該偏置自然就是0。如果該偏置不是0,那麼它要求能被memRequirements.alignment整除。

當然,就和C++動態內存分配一樣,內存應該在某個時候釋放。綁定該緩衝對象的內存可能在緩衝一旦不使用的時候就被釋放,所以我們在銷燬緩衝後釋放它:

vkFreeMemory(device, vertexBufferMemory, nullptr);

就在cleanup方法的vkDestroyBuffer之後調用。

現在是時候將頂點數據拷貝到緩衝中去了,就是使用vkMapMemory將緩衝內存映射到CPU可訪問的內存:

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);

該方法允許我們通過偏置和大小訪問一片特定的內存資源,偏置和大小這裏分別是0和bufferInfo.size。也可以指定特殊值VK_WHOLE_SIZE來映射所有內存。倒數第二個參數可以用於指定標記,但是當前API中還沒有什麼可用的,必須設置爲0。最後的參數指定了指針映射內存的輸出。

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
memcpy(data, vertices.data(), (size_t) bufferInfo.size);
vkUnmapMemory(device, vertexBufferMemory);

現在可以簡單調用memcpy來拷貝頂點數據到映射的內存,用vkUnmapMemory來取消映射。不幸的是,驅動可能不會立即拷貝數據到緩衝內存,比如在緩衝的時候。也可能寫入到緩衝在映射的內存中還不可見。

有兩種方式來應對該問題:

使用一個連續的內存堆,也就是VK_MEMORY_PROPERTY_HOST_COHERENT_BIT標記的;

寫入到映射內存後調用vkFlushMappedMemoryRanges,從映射內存讀取之前調用vkInvalidateMappedMemoryRanges;

我們用第一種方法,能保證映射內存永遠與分配內存的內容一致。記住這可能比顯式應用清空的性能差一點,但是我們會在後面的章節說明爲什麼沒關係。

清空內存區域或者使用連續內存堆意味着驅動將會知道我們寫入到緩衝,但是不意味着它們在GPU上已經可見了。數據轉移到GPU是一個在後臺進行的操作,這些明細能告訴我們它保證下一個vkQueueSubmit調用之前能夠完成。

現在剩下的就是渲染操作過程中綁定頂點緩衝。我們擴展createCommandBuffers方法來實現:

vkCmdBindPipeline(commandBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

VkBuffer vertexBuffers[] = { vertexBuffer };
VkDeviceSize offsets[] = { 0 };
vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers, offsets);

vkCmdDraw(commandBuffers[i], static_cast<uint32_t>(vertices.size()), 1, 0, 0);

vkCmdBindVertexBuffers方法用於綁定頂點緩衝到綁定上,就和我們在之前章節中建立的類似。開始的兩個參數,除了命令緩衝,指定了偏置和我們將要指定頂點緩衝的綁定數量。最後兩個參數指定了頂點緩衝數組以及開始讀取頂點數據的字節偏置。你應該改變vkCmdDraw來傳遞緩衝中頂點的個數而不是原來硬編碼的3。

現在運行代碼就能看到熟悉的三角形了,嘗試改變頂部的頂點爲白色,修改vertices數組如下:

const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 1.0f, 1.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
};

運行後三角形變成了下面這樣:

下一章我們會用一個不同的方法拷貝頂點數據到頂點緩衝,也會有更好的性能,但是也會有更多工作量。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章