Vulkan教程 - 15 索引緩衝

頂點緩衝已經能正常工作了,但是讓我們能夠從CPU訪問的內存類型可能對顯卡本身讀取來說不是最優的。最好的內存會有VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT標記,且通常在專用顯卡上不可以用CPU訪問。本章我們創建兩個頂點緩衝,一個位於CPU可訪問內存中的臨時緩衝來上傳來自頂點數組的數據,一個設備本地內存中的最終的頂點緩衝。我們使用緩衝複製命令來移動數據,從臨時緩衝移動到實際頂點緩衝中。

緩衝複製命令要求隊列族支持轉移操作,用VK_QUEUE_TRANSFER_BIT標記。一個好消息是,任意隊列族,有VK_QUEUE_GRAPHICS_BIT或者VK_QUEUE_COMPUTE_BIT能力的話,其實已經隱式支持VK_QUEUE_TRANSFER_BIT操作了。這些情況下,實現並不要顯式羅列到queueFlags中。

如果你喜歡挑戰,那麼你仍然可以嘗試使用一個不同的專門用於轉移操作的隊列族。它會要求你做以下修改:

修改QueueFamilyIndices和findQueueFamilies以顯式查找有VK_QUEUE_TRANSFER位的隊列族,但不是VK_QUEUE_GRAPHICS_BIT;

修改createLogicalDevice來獲取轉移隊列句柄;

爲已經提交到轉移隊列族的命令緩衝創建一個次命令池;

修改資源的sharingMode爲VK_SHARING_MODE_CONCURRENT,並同時指定圖形和轉移隊列族;

提交任何轉移命令如vkCmdCopyBuffer(本章我們也是用這個)到轉移隊列而不是圖形隊列。

是有一些工作量,但是我會教你很多東西,就是關於資源如何在不同隊列族間共享的內容。

因爲我們要創建多重緩衝,將緩衝創建移動到助手方法中是個不錯的想法。創建一個新的方法createBuffer,移動createVertexBuffer中的代碼(除了映射外)到它裏面:

void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties,
    VkBuffer& buffer, VkDeviceMemory& bufferMemory) {
    VkBufferCreateInfo bufferInfo = {};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

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

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

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

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

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

確保添加了緩衝大小,內存屬性和使用方法等參數以便我們用該方法創建多個不同類型的緩衝。最後兩個參數是輸出變量,以便向其寫入句柄。

現在可以從createVertexBuffer中移除緩衝創建和內存分配的代碼,然後調用createBuffer:

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
        vertexBuffer, vertexBufferMemory);

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

運行下程序,確保頂點緩衝沒有問題。

我們現在打算修改createVertexBuffer,以僅僅使用一個可見緩衝作爲臨時緩衝,並使用設備本地的一個作爲實際頂點緩衝。

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
        stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, vertices.data(), (size_t)bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
        VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
        vertexBuffer, vertexBufferMemory);
}

我們現在使用一個新的帶stagingBufferMemory的stagingBuffer用於映射和拷貝頂點數據。本章中我們將會使用兩個新的緩衝用法標記:

VK_BUFFER_USAGE_TRANSFER_SRC_BIT:在內存轉移操作中,緩衝可以用作源地址;

VK_BUFFER_USAGE_TRANSFER_DST_BIT:在內存轉移操作中,緩衝可以用作目的地。

vertexBuffer現在從設備本地類型的內存中分配,一般表示我們無法使用vkMapMemory了。但是,我們可以從stagingBuffer中拷貝數據到vertexBuffer。我們必須通過指定stagingBuffer的轉移源標記,vertexBuffer的轉移目的地標記,以及頂點緩衝用法標記,來表示我們想要那麼做。

我們現在打算寫一個方法來從一個緩衝拷貝內容到另一個:

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

}

內存轉移操作通過命令緩衝執行,就和繪製命令一樣。因此我們必須首先分配一個臨時命令緩衝。你可能希望能爲這些短暫存在的緩衝創建一個單獨的命令池,因爲實現可能會應用於內存分配優化。在這種情況下,你應該在命令池生成過程中使用VK_COMMAND_POOL_CREATE_TRANSIENT_BIT標記。

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
    VkCommandBufferAllocateInfo allocInfo = {};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
}

然後立即開始記錄命令緩衝:

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

vkBeginCommandBuffer(commandBuffer, &beginInfo);

我們爲繪製命令緩衝使用過的VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT標記這裏並不是必需的,因爲我們只是打算使用一次命令緩衝,然後從方法中用返回來等待,直到複製操作已經完成。告訴驅動我們使用VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT的意圖是一個比較好的做法。

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

緩衝的內容使用vkCmdCopyBuffer命令進行轉移。它接收源和目的緩衝作爲參數,以及一個要拷貝的區域數組。區域在VkBufferCopy結構體中定義,由一個源緩衝偏置,目的緩衝偏置和大小組成。不像是vkMapMemory命令,這裏不能指定VK_WHOLE_SIZE。

vkEndCommandBuffer(commandBuffer);

該命令緩衝只包含了複製命令,所以我們可以在此之後停止記錄。現在執行命令緩衝來完成轉移:

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

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);

不像是繪製命令,我們不用等待事件。我們就是想要立即完成緩衝上的轉移。還是有兩種方式來等待該緩衝完成。我們可以通過vkWaitForFences使用一個柵欄,或者簡單地用vkQueueWaitIdle等待轉移隊列變空閒。柵欄會允許你同時計劃多個轉移,並等待所有都完成,而不是一次只能執行一個。這樣也給驅動更多機會優化。

vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

別忘記清理用於轉移操作的命令緩衝。現在我們可以從createVertexBuffer中調用copyBuffer來將頂點數據移動到設備本地緩衝中:

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

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

從臨時緩衝拷貝數據到設備緩衝後,我們應該將其清理掉:

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

vkDestroyBuffer(device, stagingBuffer, nullptr);
vkFreeMemory(device, stagingBufferMemory, nullptr);

運行程序確保能看到原來熟悉的三角形。現在可能還看不到我們的改進,但是現在頂點數據是從高性能內存中加載的。當我們渲染更復雜幾何對象的時候會有影響。

要注意的是,真實的程序中不應該對每個緩衝調用vkAllocateMemory。內存分配數量最大值由物理設備maxMemoryAllocationCount限制,可能在高端顯卡如1080上也僅有4096而已。對大量對象分配內存的正確方法是創建一個自定義的分配器,將多個不同物體的一個分配操作使用offset參數進行切分。

你要渲染在真實程序中的3D網格常常會在多個三角形中共享頂點。就是很簡單的東西如繪製一個矩形就會發生這種事情:

繪製一個矩形需要兩個三角形,意味着我們需要一個有6個頂點的頂點緩衝。問題是,兩個頂點的數據需要重複,導致50%的冗餘。對於更復雜的網格表現會更糟,解決辦法就是使用索引緩衝。

索引緩衝實際上是一組指向頂點緩衝的指針。它允許你記錄頂點數據,對多個頂點重用已有的數據。上面的插圖表明瞭矩形的索引緩衝看起來會是什麼樣子,如果我們有一個頂點緩衝包含了所有四個不同頂點的話。第一組三個頂點定義了右上三角形,後面三個頂點定義了左下的三角形。

本章我們要修改頂點數據,添加索引數據來繪製矩形。修改頂點數據來表示四個角:

const std::vector<Vertex> vertices = {
    {{-0.5f, -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}},
    {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}
};

左上角是紅色,右上角是綠色,右下角是藍色,左下角是白色。我們添加一個新的數組indices來表示索引緩衝的內容。它應該和插圖中繪製右上和左下三角形的索引匹配:

const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0
};

索引緩衝可以使用uint16_t或者uint32_t,這取決於vertices中記錄的個數。我們還是用uint16_t,因爲我們使用的互不相同的頂點少於65535。

就和頂點數據一樣,索引需要加載到VkBuffer以便GPU能訪問。定義兩個新的類成員來存儲索引緩衝資源:

VkCommandPool commandPool;
VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

我們將要添加的createIndexBuffer方法就和createVertexBuffer基本一樣:

void createIndexBuffer() {
    VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();
    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
        stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, indices.data(), (size_t)bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT |
        VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
        indexBuffer, indexBufferMemory);

    copyBuffer(stagingBuffer, indexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

該方法在initVulkan的createVertexBuffer後調用。

只有兩處不同。一處是bufferSize現在等於索引個數乘以索引類型大小,大小就是uint16_t或者uint32_t。indexBuffer用法應該是VK_BUFFER_USAGE_INDEX_BUFFER_BIT而不是VK_BUFFER_USAGE_VERTEX_BUFFER_BIT了。除此之外,處理都是一樣的。我們創建一個臨時緩衝以便向其拷貝索引內容,然後將它拷貝到最終設備本地索引緩衝中。

索引緩衝應該在程序結尾清理掉,就和頂點緩衝一樣:

cleanupSwapChain();

vkDestroyBuffer(device, indexBuffer, nullptr);
vkFreeMemory(device, indexBufferMemory, nullptr);

vkDestroyBuffer(device, vertexBuffer, nullptr);
vkFreeMemory(device, vertexBufferMemory, nullptr);

繪製的時候使用索引緩衝涉及到對createCommandBuffers的兩處修改。我們首先需要綁定索引緩衝,就和我們之前對頂點緩衝做的工作一樣。不同之處是你只能有一個索引緩衝。很不幸,不能爲每個頂點屬性使用不同索引,所以我們還是完全複製頂點數據,即使它就有一個屬性不同。

vkCmdBindVertexBuffers(commandBuffers[i], 0, 1, vertexBuffers, offsets);
vkCmdBindIndexBuffer(commandBuffers[i], indexBuffer, 0, VK_INDEX_TYPE_UINT16);

索引緩衝用vkCmdBindIndexBuffer綁定,參數有索引緩衝,字節偏移量,索引數據類型。

只是綁定索引緩衝並不會改變什麼,我們還要修改繪製命令,告訴Vulkan使用索引緩衝。刪除vkCmdDraw,替換爲vkCmdDrawIndexed:

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

該方法的調用和vkCmdDraw類似。頭兩個參數指定了索引個數和實例個數。我們不用實例,所以就是1。索引個數表示將要傳遞到頂點緩衝上的頂點的個數。下一個參數指定索引緩衝偏置,使用1會導致顯卡開始從第二個索引讀取。倒數第二個參數指定了在索引緩衝中添加索引的時候的偏移量。最後的參數指定了實例的偏置,這裏我們不用。

現在運行程序看到如下的矩形:

你現在知道如何通過頂點緩衝重用頂點來節省內存了,這在將來加載複雜3D模型的時候尤其重要。

之前的章節已經提到,你應該分配多個資源,如同來自單個內存分配的緩衝那樣,但實際上還要多進一步。驅動開發者建議你也要存儲多個緩衝到單個VkBuffer並在類似vkCmdBindVertexBuffers的命令中使用偏置,就和頂點和索引緩衝一樣。其優勢是你的數據會更方便緩存,因爲它們更接近在一起。甚至可以對多個資源重用相同塊的內存,如果它們不是相同的渲染操作中使用,當然也要保證它們的數據是刷新過的。這就是混疊,一些Vulkan方法有明確的標記來讓你指定想要這麼做。

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