C++ 游戏开发示例(四)

原文:zh.annas-archive.org/md5/262fd2303f01348f0fc754084f6f20af

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:准备清除屏幕

在上一章中,我们启用了 Vulkan 验证层和扩展,创建了 Vulkan 应用程序和实例,选择了设备,并创建了逻辑设备。在这一章中,我们将继续探索创建清晰的屏幕图像并将其呈现到视口的过程。

在绘制图像之前,我们首先使用一个颜色值清除和擦除之前的图像。如果我们不这样做,新的图像将覆盖在之前的图像上,这将产生一种迷幻的效果。

每幅图像被清除和渲染后,然后呈现在屏幕上。当当前图像正在显示时,下一幅图像已经在后台被绘制。一旦渲染完成,当前图像将与新图像交换。这种图像交换由 SwapChain 负责处理。

在我们的案例中,我们正在绘制的 SwapChain 中的每一幅图像仅仅存储颜色信息。这个目标图像被渲染,因此被称为渲染目标。我们也可以有其他的目标图像。例如,我们可以有一个深度目标/图像,它将存储每帧每个像素的深度信息。因此,我们也创建了这些渲染目标。

每帧的每个目标图像被设置为附件并用于创建帧缓冲区。由于我们采用双缓冲(意味着我们有两套图像进行交换),我们为每一帧创建一个帧缓冲区。因此,我们将为每一帧创建两个帧缓冲区——一个用于每一帧——并将图像作为附件添加。

我们给 GPU 的命令——例如,绘制命令——通过每个帧使用命令缓冲区发送到 GPU。命令缓冲区存储所有要使用设备图形队列提交给 GPU 的命令。因此,对于每一帧,我们创建一个命令缓冲区来携带我们所有的命令。

一旦命令提交并且场景被渲染,我们不仅可以将绘制的图像呈现在屏幕上,我们还可以将其保存并添加任何后处理效果,例如运动模糊。在渲染过程中,我们可以指定渲染目标的使用方式。尽管在我们的案例中,我们不会添加任何后处理效果,但我们仍然需要创建一个渲染过程。因此,我们创建了一个渲染过程,它将指定我们将使用多少 SwapChain 图像和缓冲区,它们是什么类型的缓冲区,以及它们应该如何使用。

图像将经历的各个阶段如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/42991319-7f62-4966-8aee-afd84dc7cc22.png

本章涵盖的主题如下:

  • 创建 SwapChain

  • 创建 Renderpass

  • 使用渲染视图和 Framebuffers

  • 创建 CommandBuffer

  • 开始和结束 Renderpass

  • 创建清除屏幕

创建 SwapChain

当场景渲染时,缓冲区会交换并显示到窗口表面。表面是平台相关的,并且根据操作系统,我们必须相应地选择表面格式。为了正确显示场景,我们根据表面格式、显示模式和图片的范围(即窗口可以支持的宽度和高度)创建 SwapChain

在 第十章 的 绘制 Vulkan 对象 中,当我们选择要使用的 GPU 设备时,我们检索了设备的属性,例如表面格式和它支持的显示模式。当我们创建 SwapChain 时,我们将匹配并检查设备提供的表面格式和显示,以及窗口支持的表面格式,以创建 SwapChain 对象本身。

我们创建了一个新的类,名为 SwapChain,并将以下包含添加到 SwapChain.h

#include <vulkan\vulkan.h> 
#include <vector> 
#include <set> 
#include <algorithm>  

然后,我们创建类,如下所示:

classSwapChain { 
public: 
   SwapChain(); 
   ~SwapChain(); 

   VkSwapchainKHR swapChain; 
   VkFormat swapChainImageFormat; 
   VkExtent2D swapChainImageExtent; 

   std::vector<VkImage> swapChainImages; 

   VkSurfaceFormatKHRchooseSwapChainSurfaceFormat(
     const std::vector<VkSurfaceFormatKHR>&availableFormats); 

   VkPresentModeKHRchooseSwapPresentMode(
     const std::vector<VkPresentModeKHR>availablePresentModes); 

   VkExtent2DchooseSwapExtent(constVkSurfaceCapabilitiesKHR&capabilities); 

   void create(VkSurfaceKHRsurface); 

void destroy(); 
}  

在类的公共部分,我们创建构造函数和析构函数。然后,我们创建 VkSwapchainKHRVkFormatVkExtent2D 类型的变量来存储 swapchain 本身。当我们创建表面时,我们存储支持的图片格式以及图片的范围,即视口的宽度和高度。这是因为,当视口拉伸或改变时,swapchain 图片的大小也会相应地改变。

我们创建了一个 VkImage 类型的向量,称为 swapChainImages,用于存储 SwapChain 图片。创建了三个辅助函数,chooseSwapChainSurfaceFormatchooseSwapPresentModechooseSwapExtent,以获取最合适的表面格式、显示模式和 SwapChain 范围。最后,create 函数接收我们将在其中创建 swapchain 本身的表面。我们还添加了一个用于销毁和释放资源回系统的函数。

SwapChain.h 文件的内容就是这些。我们现在将转到 SwapChain.cpp 以包含函数的实现。

SwapChain.cpp 文件中,添加以下包含:

#include"SwapChain.h" 

#include "VulkanContext.h"

我们需要包含 VulkanContext.h 来获取设备的 SwapChainSupportDetails 结构体,这是我们在上一个章节中在选择了物理设备并创建了逻辑设备时填充的。在我们创建 swapchain 之前,让我们先看看三个辅助函数以及每个是如何创建的。

三个函数中的第一个是 chooseSwapChainSurfaceFormat。这个函数接收一个 VkSurfaceFormatKHR 类型的向量,这是设备支持的可用格式。使用这个函数,我们将选择最合适的表面格式。函数的创建方式如下:

VkSurfaceFormatKHRSwapChain::chooseSwapChainSurfaceFormat(const std::vector<VkSurfaceFormatKHR>&availableFormats) { 

   if (availableFormats.size() == 1 &&availableFormats[0].format == 
     VK_FORMAT_UNDEFINED) { 
         return{VK_FORMAT_B8G8R8A8_UNORM, 
           VK_COLOR_SPACE_SRGB_NONLINEAR_KHR }; 
   } 

   for (constauto& availableFormat : availableFormats) { 
         if (availableFormat.format == VK_FORMAT_B8G8R8A8_UNORM&& 
            availableFormat.colorSpace == 
            VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) { 
                return availableFormat; 
         } 
   } 
   returnavailableFormats[0]; 
} 

首先,我们检查可用的格式是否仅为 1,并且是否由设备未定义。这意味着没有首选格式,因此我们选择对我们最方便的一个。

返回的值是颜色格式和颜色空间。颜色格式指定了颜色本身的格式,VK_FORMAT_B8G8R8A8_UNORM,这告诉我们我们在每个像素中存储 32 位信息。颜色存储在蓝色、绿色、红色和 Alpha 通道中,并且按照这个顺序。每个通道存储 8 位,这意味着 2⁸,即 256 种颜色值。"UNORM"表明每个颜色值是归一化的,所以颜色值不是从 0-255,而是归一化在 0 和 1 之间。

我们选择 SRGB 色彩空间作为第二个参数,因为我们希望有更多的颜色范围被表示。如果没有首选格式,我们将遍历可用的格式,然后检查并返回我们需要的格式。我们选择这个色彩空间,因为大多数表面支持这种格式,因为它广泛可用。否则,我们只返回第一个可用的格式。

下一个函数是chooseSwapPresentMode,它接受一个名为availablePresentModesVkPresentModeKHR向量。展示模式指定了最终渲染的图片如何呈现到视口。以下是可用的模式:

  • VK_PRESENT_MODE_IMMEDIATE_KHR:在这种情况下,图片将在有可呈现的图片时立即显示。图片不会被排队等待显示。这会导致图片撕裂。

  • VK_PRESENT_MODE_FIFO_KHR:要呈现的获取的图片被放入一个队列中。队列的大小是交换链大小减一。在垂直同步(vsync)时,第一个要显示的图片以**先进先出(FIFO)**的方式显示。由于图片是按照它们被添加到队列中的顺序显示的,并且启用了垂直同步,所以没有撕裂。这种模式需要始终支持。

  • VK_PRESENT_MODE_FIFO_RELAXED_KHR:这是FIFO模式的一种变体。在这种模式下,如果渲染速度超过显示器的刷新率,那是可以的,但如果绘图速度慢于显示器,当下一个可用的图片立即呈现时,将会出现屏幕撕裂。

  • VK_PRESENTATION_MODE_MAILBOX_KHR:图片的展示被放入一个队列中,但它只有一个元素,与FIFO不同,FIFO队列中有多个元素。下一个要显示的图片将等待队列被显示,然后展示引擎将显示图片。这不会导致撕裂。

基于这些信息,让我们创建chooseSwapPresentMode函数:

VkPresentModeKHRSwapChain::chooseSwapPresentMode(
  const std::vector<VkPresentModeKHR>availablePresentModes) { 

   VkPresentModeKHR bestMode = VK_PRESENT_MODE_FIFO_KHR; 

   for (constauto& availablePresentMode : availablePresentModes) { 

         if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) { 
               return availablePresentMode; 
         } 
         elseif (availablePresentMode == VK_PRESENT_MODE_IMMEDIATE_KHR) { 
               bestMode = availablePresentMode; 
         } 

         return bestMode; 
   } 
} 

由于FIFO模式是我们最偏好的模式,我们在函数中将它设置好,以便我们可以将其与设备的可用模式进行比较。如果不可用,我们将选择下一个最佳模式,即MAILBOX模式,这样展示队列至少会多一个图片以避免屏幕撕裂。如果两种模式都不可用,我们将选择IMMEDIATE模式,这是最不希望的模式。

第三个函数是chooseSwapExtent函数。在这个函数中,我们获取我们绘制窗口的分辨率来设置 swapchain 图片的分辨率。它被添加如下:


VkExtent2DSwapChain::chooseSwapExtent(constVkSurfaceCapabilitiesKHR&
   capabilities) { 

   if (capabilities.currentExtent.width != std::numeric_limits<uint32_t>::max()) { 
         returncapabilities.currentExtent; 
   } 
   else { 

         VkExtent2D actualExtent = { 1280, 720 }; 

         actualExtent.width = std::max(capabilities.minImageExtent.
                              width, std::min(capabilities.
                              maxImageExtent. width, 
                              actualExtent.width)); 
         actualExtent.height = std::max(capabilities.minImageExtent.
                               height, std::min(capabilities.
                               maxImageExtent.height, 
                               actualExtent.height)); 

         return actualExtent; 
   } 
} 

这个窗口的分辨率应该与 swapchain 图片匹配。一些窗口管理器允许图片和窗口之间的分辨率不同。这可以通过将值设置为uint32_t的最大值来表示。如果不这样做,那么在这种情况下,我们将返回通过硬件能力检索到的当前范围,或者选择与最大和最小值之间的分辨率最匹配的分辨率,与实际设置的分辨率 1,280 x 720 相比。

现在我们来看一下create函数,在这个函数中我们实际上创建SwapChain本身。为了创建这个函数,我们将添加创建SwapChain的功能:

void SwapChain::create(VkSurfaceKHR surface) { 
... 
} 

我们首先做的事情是获取设备支持详情,这是我们创建Device类时为我们的设备检索到的:

SwapChainSupportDetails swapChainSupportDetails = VulkanContext::getInstance()-> getDevice()->swapchainSupport;

然后,使用我们创建的helper函数,我们获取表面格式、展示模式和范围:

   VkSurfaceFormatKHR surfaceFormat = chooseSwapChainSurfaceFormat
     (swapChainSupportDetails.surfaceFormats); 
   VkPresentModeKHR presentMode = chooseSwapPresentMode
     (swapChainSupportDetails.presentModes); 
   VkExtent2D extent = chooseSwapExtent
      (swapChainSupportDetails.surfaceCapabilities); 

然后我们设置 swapchain 所需的图片的最小数量:

uint32_t imageCount = swapChainSupportDetails.
                      surfaceCapabilities.minImageCount; 

我们还应该确保我们不超过可用的最大图片数量,所以如果imageCount超过了最大数量,我们将imageCount设置为最大计数:

   if (swapChainSupportDetails.surfaceCapabilities.maxImageCount > 0 && 
     imageCount > swapChainSupportDetails.surfaceCapabilities.
     maxImageCount) { 
         imageCount = swapChainSupportDetails.surfaceCapabilities.
                      maxImageCount; 
   } 

要创建 swapchain,我们首先必须填充VkSwapchainCreateInfoKHR结构,所以让我们创建它。创建一个名为createInfo的变量并指定结构体的类型:

   VkSwapchainCreateInfoKHR createInfo = {}; 
   createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; 

在这里,我们必须指定要使用的表面、最小图片计数、图片格式、空间和范围。我们还需要指定图片数组层。由于我们不会创建一个像虚拟现实游戏这样的立体应用,其中会有两个表面,一个用于左眼,一个用于右眼,因此我们只需将其值设置为1。我们还需要指定图片将用于什么。在这里,它将用于使用颜色附件显示颜色信息:

   createInfo.surface = surface; 
   createInfo.minImageCount = imageCount; 
   createInfo.imageFormat = surfaceFormat.format; 
   createInfo.imageColorSpace = surfaceFormat.colorSpace; 
   createInfo.imageExtent = extent; 
   createInfo.imageArrayLayers = 1; // this is 1 unless you are making
   a stereoscopic 3D application 
   createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

我们现在指定图形、展示索引和计数。我们还指定了共享模式。展示和图形家族可以是相同的,也可以是不同的。

如果展示和图形家族不同,共享模式被认为是VK_SHARING_MODE_CONCURRENT类型。这意味着图片可以在多个队列家族之间使用。然而,如果图片在同一个队列家族中,共享模式被认为是VK_SHARING_MODE_EXCLUSIVE类型:

   if (indices.graphicsFamily != indices.presentFamily) { 

         createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT; 
         createInfo.queueFamilyIndexCount = 2; 
         createInfo.pQueueFamilyIndices = queueFamilyIndices; 

   } 
   else { 

         createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; 
         createInfo.queueFamilyIndexCount = 0; 
         createInfo.pQueueFamilyIndices = nullptr; 
   } 

如果我们想,我们可以对图片应用预变换,以翻转或镜像它。在这种情况下,我们只保留当前变换。我们还可以将图片与其它窗口系统进行 alpha 混合,但我们只是保持不透明,忽略 alpha 通道,设置显示模式,并设置如果前面有窗口,像素是否应该被裁剪。我们还可以指定一个旧的SwapChain,如果当前SwapChain在调整窗口大小时变得无效。由于我们不调整窗口大小,所以我们不需要指定旧的 swapchain。

在设置信息结构后,我们可以创建 swapchain 本身:

if (vkCreateSwapchainKHR(VulkanContext::getInstance()->getDevice()->
   logicalDevice, &createInfo, nullptr, &swapChain) != VK_SUCCESS) { 
         throw std::runtime_error("failed to create swap chain !"); 
   } 

我们使用vkCreateSwapchainKHR函数创建 swapchain,该函数接受逻辑设备、createInfo结构、分配器回调和 swapchain 本身。如果由于错误而没有创建SwapChain,我们将发送错误。现在 swapchain 已创建,我们将获取 swapchain 图片。

根据图片数量,我们调用vkGetSwapchainImagesKHR函数,该函数用于首先获取图片数量,然后再次调用该函数以将图片填充到vkImage向量中:

   vkGetSwapchainImagesKHR(VulkanContext::getInstance()->getDevice()->
      logicalDevice, swapChain, &imageCount, nullptr); 
   swapChainImages.resize(imageCount); 
   vkGetSwapchainImagesKHR(VulkanContext::getInstance()->getDevice()->
      logicalDevice, swapChain, &imageCount, swapChainImages.data()); 

图片的创建稍微复杂一些,但 Vulkan 会自动创建彩色图片。我们可以设置图片格式和范围:

   swapChainImageFormat = surfaceFormat.format; 
   swapChainImageExtent= extent; 

然后,我们添加一个destroy函数,通过调用vkDestroySwapchainKHR函数来销毁SwapChain

void SwapChain::destroy(){ 

   // Swapchain 
   vkDestroySwapchainKHR(VulkanContext::getInstance()-> getDevice()->
      logicalDevice, swapChain, nullptr); 

} 

VulkanApplication.h文件中,包含SwapChain头文件并在VulkanApplication类中创建一个新的SwapChain实例。在VulkanApplication.cpp文件中,在initVulkan函数中,在创建逻辑设备之后,创建SwapChain如下:

   swapChain = new SwapChain(); 
   swapChain->create(surface);

构建并运行应用程序以确保SwapChain创建没有错误。

创建 Renderpass

在创建SwapChain之后,我们继续到Renderpass。在这里,我们指定有多少个颜色附件和深度附件,以及每个附件在帧缓冲区中使用的样本数量。

如本章开头所述,帧缓冲区是一组目标附件。附件可以是颜色、深度等类型。颜色附件存储要呈现给视口的颜色信息。还有其他用户看不到但内部使用的附件。这包括深度,例如,它包含每个像素的所有深度信息。在渲染遍历中,除了附件类型外,我们还指定了如何使用附件。

对于这本书,我们将展示场景中渲染到视口的内容,因此我们只需使用单个遍历。如果我们添加后处理效果,我们将对渲染的图片应用此效果,为此我们需要使用多个遍历。我们将创建一个新的类Renderpass,在其中我们将创建渲染遍历。

Renderpass.h文件中,添加以下包含和类:

#include <vulkan\vulkan.h> 
#include <array> 

class Renderpass 
{ 
public: 
   Renderpass(); 
   ~Renderpass(); 

   VkRenderPass renderPass; 

   void createRenderPass(VkFormat swapChainImageFormat); 

   void destroy(); 
};

在类中添加构造函数、析构函数以及VkRenderPassrenderPass变量。添加一个名为createRenderPass的新函数,用于创建Renderpass本身,它接受图片格式。还要添加一个函数,用于在用完后销毁Renderpass对象。

Renderpass.cpp文件中,添加以下包含项,以及构造函数和析构函数:

#include"Renderpass.h" 
#include "VulkanContext.h"
Renderpass::Renderpass(){} 

Renderpass::~Renderpass(){} 

现在我们添加createRenderPass函数,在其中我们将添加创建当前要渲染场景的Renderpass的功能:

voidRenderpass::createRenderPass(VkFormatswapChainImageFormat) { 
... 
} 

当我们创建渲染管线时,我们必须指定我们使用的附加项的数量和类型。因此,对于我们的项目,我们只想有颜色附加项,因为我们只会绘制颜色信息。我们也可以有一个深度附加项,它存储深度信息。我们需要提供子管线,如果有的话,那么有多少,因为我们可能使用子管线为当前帧添加后处理效果。

对于附加项和子管线,我们必须在创建渲染管线时填充结构并传递给它们。

因此,让我们填充结构。首先,我们创建附加项:

   VkAttachmentDescription colorAttachment = {}; 
   colorAttachment.format = swapChainImageFormat; 
   colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT; 
   colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; 
   colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;  
   colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; 
   colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; 

   colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; 
   colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;  

我们创建结构并指定要使用的格式,该格式与swapChainImage格式相同。我们必须提供样本数量为 1,因为我们不会使用多采样。在loadOpstoreOp中,我们指定在渲染前后对数据进行什么操作。我们指定在加载附加项时,我们将数据清除到常量值。在渲染过程之后,我们存储数据,以便我们稍后可以从中读取。然后我们决定在模板操作前后对数据进行什么操作。

由于我们不使用模板缓冲区,我们在加载和存储时指定“不关心”。我们还需要在处理图片前后指定数据布局。图片的先前布局不重要,但渲染后,图片需要改变到布局,以便它准备好进行展示。

现在我们将遍历subpass。每个subpass都引用需要指定为单独结构的附加项:

   VkAttachmentReference colorAttachRef = {}; 
   colorAttachRef.attachment = 0;  
   colorAttachRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;  

subpass引用中,我们指定了附加索引,即 0^(th)索引,并指定了布局,这是一个具有最佳性能的颜色附加。接下来,我们创建subpass结构:

   VkSubpassDescription subpass = {}; 
   subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; 
   subpass.colorAttachmentCount = 1;  
   subpass.pColorAttachments = &colorAttachRef; 

在管线绑定点中,我们指定这是一个图形子管线,因为它可能是一个计算子管线。指定附加数量为1并提供颜色附加。现在,我们可以创建渲染管线信息结构:

   std::array<VkAttachmentDescription, 1> attachments = 
      { colorAttachment }; 

   VkRenderPassCreateInfo rpCreateInfo = {}; 
   rpCreateInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; 
   rpCreateInfo.attachmentCount = static_cast<uint32_t>
                                  (attachments.size()); 
   rpCreateInfo.pAttachments = attachments.data(); 
   rpCreateInfo.subpassCount = 1; 
   rpCreateInfo.pSubpasses = &subpass; 

我们创建一个VkAttachmentDescription类型的单元素数组,然后创建信息结构并传入类型。附加项数量和附加项被传入,然后子管线数量和子管线也被传入。通过调用vkCreateRenderPass并传入逻辑设备、创建信息和分配器回调来创建渲染管线:


 if (vkCreateRenderPass(VulkanContext::getInstance()-> 
   getDevice()->logicalDevice, &rpCreateInfo, nullptr, &renderPass)
   != VK_SUCCESS) { 
         throw std::runtime_error(" failed to create renderpass !!"); 
   }

最后,在 destroy 函数中,我们在完成之后调用 vkDestroyRenderPass 来销毁它:

voidRenderpass::destroy(){ 

   vkDestroyRenderPass(VulkanContext::getInstance()-> getDevice()->
      logicalDevice, renderPass, nullptr); 

} 

VulkanApplication.h 中,包含 RenderPass.h 并创建一个渲染通道对象。在 VulkanApplication.cpp 中,在创建交换链之后,创建渲染通道:

   renderPass = new Renderpass(); 
   renderPass->createRenderPass(swapChain->swapChainImageFormat); 

现在,构建并运行项目以确保没有错误。

使用渲染目标和帧缓冲区

要使用图片,我们必须创建一个 ImageView。图片没有任何信息,例如 mipmap 级别,并且您无法访问图片的一部分。然而,通过使用图片视图,我们现在指定了纹理的类型以及它是否有 mipmap。此外,在渲染通道中,我们指定了每个帧缓冲区的附件。我们将在这里创建帧缓冲区并将图片视图作为附件传递。

创建一个名为 RenderTexture 的新类。在 RenderTexture.h 文件中,添加以下头文件然后创建该类:

 #include <vulkan/vulkan.h> 
#include<array> 

class RenderTexture 
{ 
public: 
   RenderTexture(); 
   ~RenderTexture(); 

   std::vector<VkImage> _swapChainImages; 
   VkExtent2D _swapChainImageExtent; 

   std::vector<VkImageView> swapChainImageViews; 
   std::vector<VkFramebuffer> swapChainFramebuffers; 

   void createViewsAndFramebuffer(std::vector<VkImage> swapChainImages,  
     VkFormat swapChainImageFormat, VkExtent2D swapChainImageExtent, 
     VkRenderPass renderPass); 

   void createImageViews(VkFormat swapChainImageFormat); 
   void createFrameBuffer(VkExtent2D swapChainImageExtent, 
      VkRenderPass renderPass); 

   void destroy(); 

}; 

在类中,我们像往常一样添加构造函数和析构函数。我们将存储 swapChainImages 和要本地使用的范围。我们创建两个向量来存储创建的 ImageViews 和帧缓冲区。为了创建视图和帧缓冲区,我们将调用 createViewsAndFramebuffers 函数,该函数接受图片、图片格式、范围和渲染通道作为输入。该函数将内部调用 createImageViewsCreateFramebuffer 来创建视图和缓冲区。我们将添加 destroy 函数,该函数销毁并释放资源回系统。

RenderTexture.cpp 文件中,我们还将添加以下包含以及构造函数和析构函数:

#include "RenderTexture.h" 
#include "VulkanContext.h" 
RenderTexture::RenderTexture(){} 

RenderTexture::~RenderTexture(){} 

然后,添加 createViewAndFramebuffer 函数:

void RenderTexture::createViewsAndFramebuffer(std::vector<VkImage> swapChainImages, VkFormat swapChainImageFormat,  
VkExtent2D swapChainImageExtent,  
VkRenderPass renderPass){ 

   _swapChainImages =  swapChainImages; 
   _swapChainImageExtent = swapChainImageExtent; 

   createImageViews(swapChainImageFormat); 
   createFrameBuffer(swapChainImageExtent, renderPass); 
}

我们首先将图像和 imageExtent 分配给局部变量。然后,我们调用 imageViews 函数,接着调用 createFramebuffer,以便创建它们。要创建图像视图,使用 createImageViews 函数:

void RenderTexture::createImageViews(VkFormat swapChainImageFormat){ 

   swapChainImageViews.resize(_swapChainImages.size()); 

   for (size_t i = 0; i < _swapChainImages.size(); i++) { 

         swapChainImageViews[i] = vkTools::createImageView
                                  (_swapChainImages[i], 
               swapChainImageFormat,  
               VK_IMAGE_ASPECT_COLOR_BIT); 
   } 
} 

我们首先根据交换链图像的数量指定向量的大小。对于每个图像数量,我们使用 vkTool 命名空间中的 createImageView 函数创建图像视图。createImageView 函数接受图像本身、图像格式和 ImageAspectFlag。这将根据您想要为图像创建的视图类型是 VK_IMAGE_ASPECT_COLOR_BITVK_IMAGE_ASPECT_DEPTH_BITcreateImageView 函数在 Tools.h 文件下的 vkTools 命名空间中创建。Tools.h 文件如下:

#include <vulkan\vulkan.h> 
#include <stdexcept> 
#include <vector> 

namespace vkTools { 

   VkImageView createImageView(VkImage image, VkFormat format, 
       VkImageAspectFlags aspectFlags); 

}

函数的实现创建在 Tools.cpp 文件中,如下所示:

#include "Tools.h" 
#include "VulkanContext.h"

namespace vkTools { 
   VkImageView createImageView(VkImage image, VkFormat format, VkImageAspectFlags aspectFlags) { 

         VkImageViewCreateInfo viewInfo = {}; 
         viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; 
         viewInfo.image = image; 
         viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; 
         viewInfo.format = format; 

         viewInfo.subresourceRange.aspectMask = aspectFlags; 
         viewInfo.subresourceRange.baseMipLevel = 0; 
         viewInfo.subresourceRange.levelCount = 1; 
         viewInfo.subresourceRange.baseArrayLayer = 0; 
         viewInfo.subresourceRange.layerCount = 1; 

         VkImageView imageView; 
         if (vkCreateImageView(VulkanContext::getInstance()->
            getDevice()->logicalDevice, &viewInfo, nullptr, &imageView) 
            != VK_SUCCESS) { 
               throw std::runtime_error("failed to create 
                 texture image view !"); 
         } 

         return imageView; 
   } 

} 

要创建 imageView,我们必须填充 VkImageViewCreateInfo 结构体,然后使用 vkCreateImageView 函数创建视图本身。为了填充视图信息,我们指定结构体类型、图片本身、视图类型,即 VK_IMAGE_VIEW_TYPE_2D、一个 2D 纹理,然后指定格式。我们传递 aspectFlags 用于方面掩码。我们创建的图像视图没有任何 mipmap 级别或层,因此我们将它们设置为 0。如果我们正在制作类似 VR 游戏的东西,我们才需要多个层。

然后,我们创建一个 VkImage 类型的 imageView 并使用 vkCreateImageView 函数创建它,该函数接受逻辑设备、视图信息结构体,然后创建并返回图片视图。这就是 Tools 文件的所有内容。

当我们想要可重用的函数时,我们将使用 Tools 文件并为其添加更多函数。现在,让我们回到 RenderTexture.cpp 文件并添加创建帧缓冲区的函数。

我们将为交换链中的每一帧创建帧缓冲区。createFramebuffer 函数需要图片范围和渲染通道本身:

void RenderTexture::createFrameBuffer(VkExtent2D swapChainImageExtent, VkRenderPass renderPass){ 

   swapChainFramebuffers.resize(swapChainImageViews.size()); 

   for (size_t i = 0; i < swapChainImageViews.size(); i++) { 

         std::array<VkImageView, 2> attachments = { 
               swapChainImageViews[i] 
         }; 

         VkFramebufferCreateInfo fbInfo = {}; 
         fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; 
         fbInfo.renderPass = renderPass;  
         fbInfo.attachmentCount = static_cast<uint32_t>
                                  (attachments.size());; 
         fbInfo.pAttachments = attachments.data();; 
         fbInfo.width = swapChainImageExtent.width; 
         fbInfo.height = swapChainImageExtent.height; 
         fbInfo.layers = 1;  

         if (vkCreateFramebuffer(VulkanContext::getInstance()->
            getDevice()->logicalDevice, &fbInfo, NULL, 
            &swapChainFramebuffers[i]) != VK_SUCCESS) { 

               throw std::runtime_error(" failed to create 
                  framebuffers !!!"); 
         } 
   } 
} 

对于我们创建的每一帧,帧缓冲区首先填充 framebufferInfo 结构体,然后调用 vkCreateFramebuffer 创建帧缓冲区本身。对于每一帧,我们创建一个新的信息结构体并指定结构体类型。然后我们传递渲染通道、附件数量和附件视图,指定帧缓冲区的宽度和高度,并将层设置为 1

最后,我们通过调用 vkCreateFramebuffer 函数创建帧缓冲区:

void RenderTexture::destroy(){ 

   // image views 
   for (auto imageView : swapChainImageViews) { 

         vkDestroyImageView(VulkanContext::getInstance()->getDevice()->
            logicalDevice, imageView, nullptr); 
   } 

   // Framebuffers 
   for (auto framebuffer : swapChainFramebuffers) { 
         vkDestroyFramebuffer(VulkanContext::getInstance()->
            getDevice()->logicalDevice, framebuffer, nullptr); 
   } 

} 

destroy 函数中,我们通过调用 vkDestroyImageViewvkDestroyFramebuffer 销毁我们创建的每个图像视图和帧缓冲区。这就是 RenderTexture 类的所有内容。

VulkanApplication.h 中,包含 RenderTexture.h 并在 VulkanApplication 类中创建一个名为 renderTexture 的其实例。在 VulkanApplication.cpp 文件中,包含 initVulkan 函数并创建一个新的 RenderTexture

   renderTexture = new RenderTexture(); 
   renderTexture->createViewsAndFramebuffer(swapChain->swapChainImages, 
         swapChain->swapChainImageFormat, 
         swapChain->swapChainImageExtent, 
         renderPass->renderPass); 

创建命令缓冲区

在 Vulkan 中,GPU 上执行的所有绘图和其他操作都使用命令缓冲区完成。命令缓冲区包含绘图命令,这些命令被记录并执行。绘图命令需要在每一帧中记录和执行。要创建命令缓冲区,我们必须首先创建命令池,然后从命令池中分配命令缓冲区,然后按帧记录命令。

让我们创建一个新的类来创建命令缓冲池,并分配命令缓冲区。我们还要创建一个用于开始和停止录制以及销毁命令缓冲区的函数。创建一个新的类,命名为 DrawCommandBuffer,以及 DrawCommandBuffer.h 如下所示:

#include <vulkan\vulkan.h> 
#include <vector> 

class DrawCommandBuffer 
{ 
public: 
   DrawCommandBuffer(); 
   ~DrawCommandBuffer(); 

   VkCommandPool commandPool; 
   std::vector<VkCommandBuffer> commandBuffers; 

   void createCommandPoolAndBuffer(size_t imageCount); 
   void beginCommandBuffer(VkCommandBuffer commandBuffer); 
   void endCommandBuffer(VkCommandBuffer commandBuffer); 

   void createCommandPool(); 
   void allocateCommandBuffers(size_t imageCount); 

   void destroy(); 
}; 

在类中,我们创建构造函数和析构函数。我们创建变量来存储命令池和一个向量来存储VkCommandBuffer。我们最初创建一个函数来创建命令池和分配命令缓冲区。接下来的两个函数beginCommandBufferendCommandBuffer将在我们想要开始和停止记录命令缓冲区时被调用。createCommandPoolallocateCommandBuffers函数将由createCommandPoolAndBuffer调用。

当我们想要将资源释放给系统时,我们将创建destroy函数来销毁命令缓冲区。在CommandBuffer.cpp中,添加必要的包含文件和构造函数及析构函数:

#include "DrawCommandBuffer.h" 
#include "VulkanContext.h"

DrawCommandBuffer::DrawCommandBuffer(){} 

DrawCommandBuffer::~DrawCommandBuffer(){}   

然后,我们添加createCommandPoolAndBuffer,它接受图片数量:

void DrawCommandBuffer::createCommandPoolAndBuffer(size_t imageCount){ 

   createCommandPool(); 
   allocateCommandBuffers(imageCount); 
}

createCommandPoolAndBuffer函数将调用createCommandPoolallocateCommandBuffers函数。首先,我们创建createCommandPool函数。命令必须发送到特定的队列。当我们创建命令池时,我们必须指定队列:

void DrawCommandBuffer::createCommandPool() { 

   QueueFamilyIndices qFamilyIndices = VulkanContext::
                                       getInstance()->getDevice()-> 
                                       getQueueFamiliesIndicesOfCurrentDevice(); 

   VkCommandPoolCreateInfo cpInfo = {}; 

   cpInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; 
   cpInfo.queueFamilyIndex = qFamilyIndices.graphicsFamily; 
   cpInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; 

   if (vkCreateCommandPool(VulkanContext::getInstance()->
       getDevice()->logicalDevice, &cpInfo, nullptr, &commandPool) 
       != VK_SUCCESS) { 
          throw std::runtime_error(" failed to create command pool !!"); 
   } 

} 

首先,我们获取当前设备的队列家族索引。为了创建命令池,我们必须填充VkCommandPoolCreateInfo结构体。像往常一样,我们指定类型。然后,我们设置池必须创建的队列家族索引。之后,我们设置VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志,这将每次重置命令缓冲区的值。然后,我们通过传递逻辑设备和信息结构体来使用vkCreateCommandPool函数获取命令池。接下来,我们创建allocateCommandBuffers函数:

void DrawCommandBuffer::allocateCommandBuffers(size_t imageCount) { 

   commandBuffers.resize(imageCount); 

   VkCommandBufferAllocateInfo cbInfo = {}; 
   cbInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; 
   cbInfo.commandPool = commandPool; 
   cbInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; 
   cbInfo.commandBufferCount = (uint32_t)commandBuffers.size(); 

   if (vkAllocateCommandBuffers(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &cbInfo, commandBuffers.data()) 
      != VK_SUCCESS) { 

         throw std::runtime_error(" failed to allocate 
            command buffers !!"); 
   } 

} 

我们调整commandBuffers向量的大小。然后,为了分配命令缓冲区,我们必须填充VkCommandBufferAllocateInfo。我们首先设置结构体的类型和命令池。然后,我们必须指定命令缓冲区的级别。你可以有一个命令缓冲区的链,其中主命令缓冲区包含次级命令缓冲区。对于我们的用途,我们将命令缓冲区设置为主要的。然后,我们设置commandBufferCount,它等于交换链的图片数量。

然后,我们使用vkAllocateCommandBuffers函数分配命令缓冲区。我们传递逻辑设备、信息结构体和要分配内存的命令缓冲区。

然后,我们添加beginCommandBuffer。这个函数接受当前命令缓冲区以开始在其中记录命令:


void DrawCommandBuffer::beginCommandBuffer(VkCommandBuffer commandBuffer){ 

   VkCommandBufferBeginInfo cbBeginInfo = {}; 

   cbBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; 

   cbBeginInfo.flags = VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT; 

   if (vkBeginCommandBuffer(commandBuffer, &cbBeginInfo) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to begin command buffer !!"); 
   } 

}

为了记录命令缓冲区,我们还需要填充VkCommandBufferBeginInfoStruct。再次指定结构体类型和VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT标志。这使我们能够在上一帧仍在使用时调度下一帧的命令缓冲区。通过传递当前命令缓冲区来调用vkBeginCommandBuffer以开始记录命令。

接下来,我们添加endCommandBuffer函数。这个函数只是调用vkEndCommandBuffer来停止向命令缓冲区记录:

void DrawCommandBuffer::endCommandBuffer(VkCommandBuffer commandBuffer){ 

   if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to record command buffer"); 
   } 

} 

我们可以使用Destroy函数销毁命令缓冲区和池。在这里,我们只销毁池,这将销毁命令缓冲区:

void DrawCommandBuffer::destroy(){ 

   vkDestroyCommandPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, commandPool, nullptr); 

} 

VulkanApplication.h文件中,包含DrawCommandBuffer.h并创建此类的对象。在VulkanApplication.cpp文件中,在VulkanInit函数中,在创建renderViewsAndFrameBuffers之后,创建DrawCommandBuffer

   renderTexture = new RenderTexture(); 
   renderTexture->createViewsAndFramebuffer(swapChain->swapChainImages, 
         swapChain->swapChainImageFormat, 
         swapChain->swapChainImageExtent, 
         renderPass->renderPass); 

   drawComBuffer = new DrawCommandBuffer(); 
   drawComBuffer->createCommandPoolAndBuffer(swapChain->
     swapChainImages.size());

开始和结束Renderpass

除了在每一帧中记录的命令外,每一帧的 renderpass 也会被处理,其中颜色和深度信息被重置。因此,由于我们每一帧中只有颜色附加层,我们必须为每一帧清除颜色信息。回到Renderpass.h文件,并在类中添加两个新函数,分别称为beginRenderPassendRenderPass,如下所示:

class Renderpass 
{ 
public: 
   Renderpass(); 
   ~Renderpass(); 

   VkRenderPass renderPass; 

   void createRenderPass(VkFormat swapChainImageFormat); 

   void beginRenderPass(std::array<VkClearValue, 1> 
      clearValues, VkCommandBuffer commandBuffer, VkFramebuffer 
      swapChainFrameBuffer, VkExtent2D swapChainImageExtent); 

   void endRenderPass(VkCommandBuffer commandBuffer); 

   void destroy(); 
}; 

RenderPass.cpp中,添加beginRenderPass函数的实现:

void Renderpass::beginRenderPass(std::array<VkClearValue, 1> clearValues, 
    VkCommandBuffer commandBuffer, VkFramebuffer swapChainFrameBuffer, 
    VkExtent2D swapChainImageExtent) { 

   VkRenderPassBeginInfo rpBeginInfo = {}; 
   rpBeginInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; 
   rpBeginInfo.renderPass = renderPass; 
   rpBeginInfo.framebuffer = swapChainFrameBuffer; 
   rpBeginInfo.renderArea.offset = { 0,0 }; 
   rpBeginInfo.renderArea.extent = swapChainImageExtent; 

   rpBeginInfo.pClearValues = clearValues.data(); 
   rpBeginInfo.clearValueCount = static_cast<uint32_t>(clearValues.size()); 

   vkCmdBeginRenderPass(commandBuffer,&rpBeginInfo,
      VK_SUBPASS_CONTENTS_INLINE); 
} 

我们接下来填充VkRenderPassBeginInfo结构体。在这里,我们指定结构体类型,传入 renderpass 和当前 framebuffer,设置渲染区域为整个 viewport,并传入清除值和计数。清除值是我们想要清除屏幕的颜色值,计数将是1,因为我们只想清除颜色附加层。

要开始Renderpass,我们传入当前命令缓冲区、信息结构体,并将第三个参数指定为VK_SUBPASS_CONTENTS_INLINE,指定 renderpass 命令绑定到主命令缓冲区。

endCommandBuffer函数中,我们完成当前帧的Renderpass

void Renderpass::endRenderPass(VkCommandBuffer commandBuffer){ 

   vkCmdEndRenderPass(commandBuffer); 
} 

要结束Renderpass,调用vkCmdEndRenderPass函数并传入当前命令缓冲区。

我们已经有了所需的类来开始清除屏幕。现在,让我们转到 Vulkan 应用程序类,并添加一些代码行以使其工作。

创建清除屏幕

VulkanApplication.h文件中,我们将添加三个新函数,分别称为drawBegindrawEndcleanupdrawBegin将在传递任何绘图命令之前被调用,drawEnd将在绘图完成后、帧准备好呈现到 viewport 时被调用。在cleanup函数中,我们将销毁所有资源。

我们还将创建两个变量。第一个是uint32_t,用于从 swapchain 获取当前图片,第二个是currentCommandBuffer,类型为VkCommandBuffer,用于获取当前命令缓冲区:

public: 

   static VulkanApplication* getInstance(); 
   static VulkanApplication* instance; 

   ~VulkanApplication(); 

   void initVulkan(GLFWwindow* window); 

   void drawBegin(); 
   void drawEnd(); 
void cleanup(); 

private: 

   uint32_t imageIndex = 0; 
   VkCommandBuffer currentCommandBuffer; 

   //surface 
   VkSurfaceKHR surface; 

VulkanApplication.cpp文件中,我们添加drawBegindrawEnd函数的实现:

void VulkanApplication::drawBegin(){ 

   vkAcquireNextImageKHR(VulkanContext::getInstance()->
      getDevice()->logicalDevice, 
         swapChain->swapChain, 
         std::numeric_limits<uint64_t>::max(), 
         NULL,  
         VK_NULL_HANDLE, 
         &imageIndex); 

   currentCommandBuffer = drawComBuffer->commandBuffers[imageIndex]; 

   // Begin command buffer recording 
   drawComBuffer->beginCommandBuffer(currentCommandBuffer); 

   // Begin renderpass 
   VkClearValue clearcolor = { 1.0f, 0.0f, 1.0f, 1.0f }; 

   std::array<VkClearValue, 1> clearValues = { clearcolor }; 

   renderPass->beginRenderPass(clearValues, 
         currentCommandBuffer, 
         renderTexture->swapChainFramebuffers[imageIndex], 
         renderTexture->_swapChainImageExtent); 

} 

首先,我们从交换链中获取下一张图片。这是通过使用 Vulkan 的vkAcquireNextImageKHR API 调用来完成的。为此,我们传递逻辑设备和交换链实例。接下来,我们需要传递超时时间,因为我们不关心时间限制,所以我们传递最大数值。接下来的两个变量保持为 null。这些需要信号量和栅栏,我们将在后面的章节中讨论。最后,我们传递imageIndex本身。

然后,我们从命令缓冲区向量中获取当前命令缓冲区。我们通过调用beginCommandBuffer开始记录命令缓冲区,命令将被存储在currentCommandBuffer对象中。我们现在开始渲染过程。在这个过程中,我们传递清除颜色值,这是紫色,因为为什么不呢?!传递当前commandbuffer、帧缓冲区和图片范围。

我们现在可以实现drawEnd函数:

void VulkanApplication::drawEnd(){ 

   // End render pass commands 
   renderPass->endRenderPass(currentCommandBuffer); 

   // End command buffer recording 
   drawComBuffer->endCommandBuffer(currentCommandBuffer); 

   // submit command buffer 
   VkSubmitInfo submitInfo = {}; 
   submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; 
   submitInfo.commandBufferCount = 1; 
   submitInfo.pCommandBuffers = &currentCommandBuffer; 

   vkQueueSubmit(VulkanContext::getInstance()->getDevice()->
      graphicsQueue, 1, &submitInfo, NULL); 

   // Present frame 
   VkPresentInfoKHR presentInfo = {}; 
   presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; 
   presentInfo.swapchainCount = 1; 
   presentInfo.pSwapchains = &swapChain->swapChain; 
   presentInfo.pImageIndices = &imageIndex; 

   vkQueuePresentKHR(VulkanContext::getInstance()->
       getDevice()->presentQueue, &presentInfo); 

   vkQueueWaitIdle(VulkanContext::getInstance()->
       getDevice()->presentQueue); 

} 

我们结束渲染过程并停止向命令缓冲区记录。然后,我们必须提交命令缓冲区和呈现帧。要提交命令缓冲区,我们创建一个VkSubmitInfo结构体,并用结构体类型、每帧的缓冲区计数(每帧 1 个)和命令缓冲区本身填充它。通过调用vkQueueSubmit并将图形队列、提交计数和提交信息传递进去,命令被提交到图形队列。

一旦渲染了帧,它就会通过呈现队列呈现到视口。

要在绘制完成后呈现场景,我们必须创建并填充VkPresentInfoKHR结构体。对于呈现,图片被发送回交换链。当我们创建信息和设置结构体的类型时,我们还需要设置交换链、图像索引和交换链计数,该计数为 1。

然后,我们使用vkQueuePresentKHR通过传递呈现队列和呈现信息到函数中来呈现图片。最后,我们使用vkQueueWaitIdle函数等待主机完成给定队列的呈现操作,该函数接受呈现队列。此外,当你完成使用资源时,最好清理资源,因此添加cleanup函数:

void VulkanApplication::cleanup() { 

   vkDeviceWaitIdle(VulkanContext::getInstance()->
     getDevice()->logicalDevice); 

   drawComBuffer->destroy(); 
   renderTexture->destroy(); 
   renderPass->destroy(); 
   swapChain->destroy(); 

   VulkanContext::getInstance()->getDevice()->destroy(); 

   valLayersAndExt->destroy(vInstance->vkInstance, 
      isValidationLayersEnabled); 

   vkDestroySurfaceKHR(vInstance->vkInstance, surface, nullptr);   
   vkDestroyInstance(vInstance->vkInstance, nullptr); 

} 
 delete drawComBuffer;
 delete renderTarget;
 delete renderPass;
 delete swapChain;
 delete device;

 delete valLayersAndExt;
 delete vInstance;

 if (instance) {
  delete instance;
  instance = nullptr;
 }

当我们销毁对象时,我们必须调用vkDeviceWaitIdle来停止使用设备。然后,我们以相反的顺序销毁对象。因此,我们首先销毁命令缓冲区,然后是渲染纹理资源,然后是渲染过程,然后是交换链。然后我们销毁设备、验证层、表面,最后是 Vulkan 实例。最后,我们删除为DrawCommandBufferRenderTargetRenderpassSwapchainDeviceValidationLayersAndExtensionsVulkanInstance创建的类实例。

最后,我们删除VulkanContext的实例,并在删除后将其设置为nullptr

source.cpp文件中的while循环中,调用drawBegindrawEnd函数。然后在循环后调用cleanup函数:

#define GLFW_INCLUDE_VULKAN 
#include<GLFW/glfw3.h> 

#include "VulkanApplication.h" 

int main() { 

   glfwInit(); 

   glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); 
   glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); 

   GLFWwindow* window = glfwCreateWindow(1280, 720, 
                        "HELLO VULKAN ", nullptr, nullptr); 

   VulkanApplication::getInstance()->initVulkan(window); 

   while (!glfwWindowShouldClose(window)) { 

         VulkanApplication::getInstance()->drawBegin(); 

         // draw command  

         VulkanApplication::getInstance()->drawEnd(); 

         glfwPollEvents(); 
   }               

   VulkanApplication::getInstance()->cleanup(); 

   glfwDestroyWindow(window); 
   glfwTerminate(); 

   return 0; 
} 

当你构建并运行命令时,你会看到一个紫色的视口,如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/e6152ccd-b527-4376-b15f-b536ea987712.png

屏幕看起来没问题,但如果你查看控制台,你会看到以下错误,它说当我们调用vkAcquireNextImageKHR时,信号量和栅栏都不能为NULL

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/e720f47c-eff8-4fde-9e32-8689885d1073.png

摘要

在本章中,我们探讨了交换链、渲染通道、渲染视图、帧缓冲区和命令缓冲区的创建。我们还探讨了每个的作用以及为什么它们对于渲染清晰的屏幕很重要。

在下一章中,我们将创建资源,使我们能够将几何图形渲染到视口中。一旦我们准备好了对象资源,我们将渲染这些对象。然后我们将探讨信号量和栅栏以及为什么它们是必需的。

第十一章:创建对象资源

在上一章中,我们使清除屏幕功能正常工作并创建了 Vulkan 实例。我们还创建了逻辑设备、交换链、渲染目标和视图,以及绘制命令缓冲区,以记录和提交命令到 GPU。使用它,我们能够得到一个紫色的清除屏幕。我们还没有绘制任何几何形状,但现在我们已准备好这样做。

在本章中,我们将准备好渲染几何形状所需的大部分内容。我们必须创建顶点、索引和统一缓冲区。顶点、索引和统一缓冲区将包含有关顶点属性的信息,例如位置、颜色、法线和纹理坐标;索引信息将包含我们想要绘制的顶点的索引,统一缓冲区将包含如新的视图投影矩阵等信息。

我们需要创建一个描述符集和布局,这将指定统一缓冲区绑定到哪个着色器阶段。

我们还必须生成用于绘制几何形状的着色器。

为了创建对象缓冲区和描述符集以及布局,我们将创建新的类,以便它们被分离开来,我们可以理解它们是如何相关的。在我们跳到对象缓冲区类之前,我们将添加在 OpenGL 项目中创建的 Mesh 类,并且我们将使用相同的类并对它进行一些小的修改。Mesh 类包含有关我们想要绘制的不同几何形状的顶点和索引信息。

本章我们将涵盖以下主题:

  • 更新 Mesh 类以支持 Vulkan

  • 创建 ObjectBuffers

  • 创建 Descriptor

  • 创建 SPIR-V 着色器二进制文件

更新 Mesh 类以支持 Vulkan

Mesh.h 文件中,我们只需添加几行代码来指定 InputBindingDescriptionInputAttributeDescription。在 InputBindingDesciption 中,我们指定绑定位置、数据本身的步长以及输入速率,它指定数据是按顶点还是按实例。在 OpenGL 项目的 Mesh.h 文件中,我们只需向 Vertex 结构体添加函数:

 struct Vertex { 

   glm::vec3 pos; 
   glm::vec3 normal; 
   glm::vec3 color; 
glm::vec2 texCoords; 

}; 

因此,在Vertex结构体中,添加一个用于检索AttributeDescription的函数:

   static VkVertexInputBindingDescription getBindingDescription() { 

         VkVertexInputBindingDescription bindingDescription = {}; 

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

         return bindingDescription; 
} 

在函数 VertexInputBindingDescriptor 中,指定绑定位于第 0 个索引,步长等于 Vertex 结构体本身的大小,输入速率是 VK_VERTEX_INPUT_RATE_VERTEX,即按顶点。该函数仅返回创建的绑定描述。

由于我们在顶点结构体中有四个属性,我们必须为每个属性创建一个属性描述符。将以下函数添加到 Vertex 结构体中,该函数返回一个包含四个输入属性描述符的数组。对于每个属性描述符,我们必须指定绑定位置,即绑定描述中指定的 0,每个属性的布局位置,数据类型的格式,以及从 Vertex 结构体开始的偏移量:

static std::array<VkVertexInputAttributeDescription, 4> getAttributeDescriptions() { 

   std::array<VkVertexInputAttributeDescription, 4> 
   attributeDescriptions = {}; 

   attributeDescriptions[0].binding = 0; // binding index, it is 0 as 
                                            specified above 
   attributeDescriptions[0].location = 0; // location layout

   // data format
   attributeDescriptions[0].format = VK_FORMAT_R32G32B32_SFLOAT; 
   attributeDescriptions[0].offset = offsetof(Vertex, pos); // bytes             
      since the start of the per vertex data 

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

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

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

   return attributeDescriptions; 
}   

我们还将在 Mesh.h 文件中创建一个新的结构体来组织统一数据信息。因此,创建一个名为 UniformBufferObject 的新结构体:

struct UniformBufferObject { 

   glm::mat4 model; 
   glm::mat4 view; 
   glm::mat4 proj; 

}; 

Mesh.h 文件顶部,我们还将包含两个 define 语句来告诉 GLM 使用弧度而不是度数,并使用归一化深度值:

#define GLM_FORCE_RADIAN 
#define GLM_FORCE_DEPTH_ZERO_TO_ONE 

对于 Mesh.h 文件来说,这就结束了。Mesh.cpp 文件完全没有被修改。

创建 ObjectBuffers

为了创建与对象相关的缓冲区,例如顶点、索引和统一缓冲区,我们将创建一个新的类,称为 ObjectBuffers。在 ObjectBuffers.h 文件中,我们将添加所需的 include 语句:

#include <vulkan\vulkan.h> 
#include <vector> 

#include "Mesh.h"  

然后,我们将创建类本身。在公共部分,我们将添加构造函数和析构函数,并添加创建顶点、索引和统一缓冲区所需的数据类型。我们添加一个数据顶点的向量来设置几何体的顶点信息,创建一个名为 vertexBufferVkBuffer 实例来存储顶点缓冲区,并创建一个名为 vertexBufferMemoryVkDeviceMemory 实例:

  • VkBuffer:这是对象缓冲区的句柄。

  • VkDeviceMemory:Vulkan 通过 DeviceMemory 对象在设备的内存中操作内存数据。

类似地,我们创建一个向量来存储索引,并创建一个 indexBufferindexBufferMemory 对象,就像我们为顶点所做的那样。

对于统一缓冲区,我们只创建 uniformBufferuniformBuffer 内存,因为不需要向量。

我们添加了一个 createVertexIndexUniformBuffers 函数,它接受一个 Mesh 类型,并且顶点和索引将根据它设置。

我们还添加了一个销毁函数来销毁我们创建的 Vulkan 对象。

在私有部分,我们添加了三个函数,createVertexIndexUniformBuffers 将会调用这些函数来创建缓冲区。这就是 ObjectBuffers.h 文件的全部内容。因此,ObjectBuffers 类应该如下所示:

class ObjectBuffers 
{ 
public: 
   ObjectBuffers(); 
   ~ObjectBuffers(); 

   std::vector<Vertex> vertices; 
   VkBuffer vertexBuffer; 
   VkDeviceMemory vertexBufferMemory; 

   std::vector<uint32_t> indices; 
   VkBuffer indexBuffer; 
   VkDeviceMemory indexBufferMemory; 

   VkBuffer uniformBuffers; 
   VkDeviceMemory uniformBuffersMemory; 

   void createVertexIndexUniformsBuffers(MeshType modelType); 
   void destroy(); 

private: 

   void createVertexBuffer(); 
   void createIndexBuffer(); 
   void createUniformBuffers(); 

}; 

接下来,让我们继续转到 ObjectBuffers.cpp 文件。在这个文件中,我们包含头文件并创建构造函数和析构函数:

#include "ObjectBuffers.h" 
#include "Tools.h" 
#include "VulkanContext.h" 

ObjectBuffers::ObjectBuffers(){} 

ObjectBuffers::~ObjectBuffers(){} 

Tools.h 被包含进来,因为我们将会向其中添加一些我们将要使用的功能。接下来,我们将创建 createVertexIndexUniformBuffers 函数:

void ObjectBuffers::createVertexIndexUniformsBuffers(MeshType modelType){ 

   switch (modelType) { 

         case kTriangle: Mesh::setTriData(vertices, indices); break; 
         case kQuad: Mesh::setQuadData(vertices, indices); break; 
         case kCube: Mesh::setCubeData(vertices, indices); break; 
         case kSphere: Mesh::setSphereData(vertices, indices); break; 

   } 

    createVertexBuffer(); 
    createIndexBuffer(); 
    createUniformBuffers(); 

}

与 OpenGL 项目类似,我们将添加一个 switch 语句来根据网格类型设置顶点和索引数据。然后我们调用 createVertexBuffer

createIndexBuffercreateUniformBuffers 函数来设置相应的缓冲区。我们首先创建 createVertexBuffer 函数。

为了创建顶点缓冲区,最好在GPU本身上的设备上创建缓冲区。现在,GPU有两种类型的内存:HOST VISIBLEDEVICE LOCALHOST VISIBLE是 CPU 可以访问的 GPU 内存的一部分。这种内存不是很大,因此用于存储最多 250 MB 的数据。

对于较大的数据块,例如顶点和索引数据,最好使用DEVICE LOCAL内存,CPU 无法访问这部分内存。

那么,如何将数据传输到DEVICE LOCAL内存呢?首先,我们必须将数据复制到GPU上的HOST VISIBLE部分,然后将其复制到DEVICE LOCAL内存。因此,我们首先创建一个称为阶段缓冲区的东西,将顶点数据复制进去,然后将阶段缓冲区复制到实际的顶点缓冲区:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/cc52a322-ee49-4ab3-b470-b047522a29f1.png

(来源:www.youtube.com/watch?v=rXSdDE7NWmA

让我们在VkTool文件中添加创建不同类型缓冲区的功能。这样,我们就可以创建阶段缓冲区和顶点缓冲区本身。因此,在VkTools.h文件中的vkTools命名空间中,添加一个名为createBuffer的新函数。此函数接受五个参数:

  • 第一项是VkDeviceSize,这是要创建的缓冲区数据的大小。

  • 第二项是usage标志,它告诉我们缓冲区将要用于什么。

  • 第三点是内存属性,这是我们想要创建缓冲区的地方;这里我们将指定我们希望它在 HOST VISIBLE 部分还是 DEVICE LOCAL 区域。

  • 第四点是缓冲区本身。

  • 第五点是缓冲区内存,用于将缓冲区绑定到以下内容:

namespace vkTools { 

   VkImageView createImageView(VkImage image, 
         VkFormat format, 
         VkImageAspectFlags aspectFlags); 

   void createBuffer(VkDeviceSize size, 
         VkBufferUsageFlags usage, 
         VkMemoryPropertyFlags properties, 
         VkBuffer &buffer, 
         VkDeviceMemory& bufferMemory); 
} 

VKTools.cpp文件中,我们添加了创建缓冲区并将其绑定到bufferMemory的功能。在命名空间中添加新的函数:

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

// code  
} 

在绑定缓冲区之前,我们首先创建缓冲区本身。因此,我们按照以下方式填充VkBufferCreateInfo结构体:

   VkBufferCreateInfo bufferInfo = {}; 
   bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; 
   bufferInfo.size = size; 
   bufferInfo.usage = usage; 
   bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; 

   if (vkCreateBuffer(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &bufferInfo, 
      nullptr, &buffer) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create 
           vertex buffer "); 
   }

结构体首先采用通常的类型,然后我们设置缓冲区大小和用途。我们还需要指定缓冲区共享模式,因为缓冲区可以在队列之间共享,例如图形和计算,或者可能仅限于一个队列。因此,在这里我们指定缓冲区仅限于当前队列。

然后,通过调用vkCreateBuffer并传入logicalDevicebufferInfo来创建缓冲区。接下来,为了绑定缓冲区,我们必须获取适合我们特定缓冲区用途的合适内存类型。因此,首先我们必须获取我们正在创建的缓冲区类型的内存需求。必需的内存需求是通过调用vkGetBufferMemoryRequirements函数接收的,该函数接受逻辑设备、缓冲区和内存需求存储在一个名为VkMemoryRequirements的变量类型中。

我们按照以下方式获取内存需求:

   VkMemoryRequirements memrequirements; 
   vkGetBufferMemoryRequirements(VulkanContext::getInstance()->getDevice()->
     logicalDevice, buffer, &memrequirements);  

要绑定内存,我们必须填充 VkMemoryAllocateInfo 结构体。它需要分配大小和所需内存类型的内存索引。每个 GPU 都有不同的内存类型索引,具有不同的堆索引和内存类型。以下是 1080Ti 的对应值:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/48567b6b-285e-4469-8643-0b026dd39363.png

我们现在将在 VkTools 中添加一个新函数来获取适合我们缓冲区使用的正确类型的内存索引。因此,在 VkTool.h 中的 vkTools 命名空间下添加一个新函数,称为 findMemoryTypeIndex

uint32_t findMemoryTypeIndex(uint32_t typeFilter, VkMemoryPropertyFlags 
    properties); 

它接受两个参数,即可用的内存类型位和所需的内存属性。将 findMemoryTypeIndex 函数的实现添加到 VkTools.cpp 文件中。在命名空间下,添加以下函数:

uint32_t findMemoryTypeIndex(uint32_t typeFilter, VkMemoryPropertyFlags properties) { 

   //-- Properties has two arrays -- memory types and memory heaps 
   VkPhysicalDeviceMemoryProperties memProperties; 
     vkGetPhysicalDeviceMemoryProperties(VulkanContext::
     getInstance()->getDevice()->physicalDevice, 
     &memProperties); 

   for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) { 

         if ((typeFilter & (1 << i)) &&  
             (memProperties.memoryTypes[i].propertyFlags &                                 
              properties) == properties) { 

                     return i; 
               } 
         } 

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

此函数使用 vkGetPhysicalDeviceMemoryProperties 函数获取设备的内存属性,并填充物理设备的内存属性。

内存属性获取每个索引的内存堆和内存类型的信息。从所有可用索引中,我们选择我们所需的内容并返回值。一旦函数创建完成,我们就可以回到绑定缓冲区。因此,继续我们的 createBuffer 函数,向其中添加以下内容以绑定缓冲区到内存:

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

   if (vkAllocateMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &allocInfo, nullptr, 
      &bufferMemory) != VK_SUCCESS) { 

         throw std::runtime_error("failed to allocate 
            vertex buffer memory"); 
   } 

   vkBindBufferMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, buffer, 
      bufferMemory, 0); 

在所有这些之后,我们可以回到 ObjectBuffers 实际创建 createVertexBuffers 函数。因此,创建函数如下:

void ObjectBuffers::createVertexBuffer() { 
// code 
} 

在其中,我们首先创建阶段缓冲区,将顶点数据复制到其中,然后将阶段缓冲区复制到顶点缓冲区。在函数中,我们首先获取总缓冲区大小,这是顶点数和每个顶点存储的数据大小:

VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size(); 

接下来,我们创建阶段缓冲区和 stagingBufferMemory 以将阶段缓冲区绑定到它:

VkBuffer stagingBuffer; 
VkDeviceMemory stagingBufferMemory; 

然后我们调用新创建的 createBuffervkTools 中创建缓冲区:

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

在其中,我们传入我们想要的尺寸、使用方式和内存类型,以及缓冲区和缓冲区内存。VK_BUFFER_USAGE_TRANSFER_SRC_BIT 表示该缓冲区将在数据传输时作为源传输命令的一部分使用。

VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 表示我们希望它在 GPU 上的主机可见(CPU)内存空间中分配。

VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 表示 CPU 缓存管理不是由我们完成,而是由系统完成。这将确保映射内存与分配的内存匹配。接下来,我们使用 vkMapMemory 获取阶段缓冲区的宿主指针并创建一个 void 指针称为 data。然后调用 vkMapMemory 获取映射内存的指针:

   void* data; 

   vkMapMemory(VulkanContext::getInstance()->getDevice()->
      logicalDevice, stagingBufferMemory, 
         0, // offet 
         bufferSize,// size 
         0,// flag 
         &data);  

VkMapMemory 接收逻辑设备、阶段缓冲区绑定,我们指定 0 作为偏移量,并传递缓冲区大小。没有特殊标志,因此我们传递 0 并获取映射内存的指针。我们使用 memcpy 将顶点数据复制到数据指针:

memcpy(data, vertices.data(), (size_t)bufferSize);  

当不再需要主机对它的访问时,我们取消映射阶段内存:

vkUnmapMemory(VulkanContext::getInstance()->getDevice()->logicalDevice, 
   stagingBufferMemory); 

现在数据已存储在阶段缓冲区中,接下来创建顶点缓冲区并将其绑定到vertexBufferMemory

// Create Vertex Buffer 
   vkTools::createBuffer(bufferSize, 
         VK_BUFFER_USAGE_TRANSFER_DST_BIT | 
         VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, 
         VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,  
         vertexBuffer, 
         vertexBufferMemory);

我们使用createBuffer函数来创建顶点缓冲区。我们传入缓冲区大小。对于缓冲区用途,我们指定当我们将阶段缓冲区传输到它时,它用作传输命令的目标,并且它将用作顶点缓冲区。对于内存属性,我们希望它在DEVICE_LOCAL中创建以获得最佳性能。传递顶点缓冲区和顶点缓冲区内存以绑定缓冲区到内存。现在,我们必须将阶段缓冲区复制到顶点缓冲区。

在 GPU 上复制缓冲区必须使用传输队列和命令缓冲区。我们可以像检索图形和显示队列一样获取传输队列来完成传输。好消息是,我们不需要这样做,因为所有图形和计算队列也支持传输功能,所以我们将使用图形队列来完成。

我们将在vkTools命名空间中创建两个辅助函数来创建和销毁临时命令缓冲区。因此,在VkTools.h文件中,在命名空间中添加两个函数用于开始和结束单次命令:

VkCommandBuffer beginSingleTimeCommands(VkCommandPool commandPool); 
   void endSingleTimeCommands(VkCommandBuffer commandBuffer, 
   VkCommandPool commandPool);  

基本上,beginSingleTimeCommands为我们返回一个命令缓冲区以供使用,而endSingleTimeCommands销毁命令缓冲区。在VkTools.cpp文件中,在命名空间下添加这两个函数:

   VkCommandBuffer beginSingleTimeCommands(VkCommandPool commandPool) { 

         //-- Alloc Command buffer   
         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(VulkanContext::getInstance()->
           getDevice()->logicalDevice, 
           &allocInfo, &commandBuffer); 

         //-- Record command buffer 

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

         //start recording 
         vkBeginCommandBuffer(commandBuffer, &beginInfo); 

         return commandBuffer; 

   } 

   void endSingleTimeCommands(VkCommandBuffer commandBuffer, 
      VkCommandPool commandPool) { 

         //-- End recording 
         vkEndCommandBuffer(commandBuffer); 

         //-- Execute the Command Buffer to complete the transfer 
         VkSubmitInfo submitInfo = {}; 
         submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; 
         submitInfo.commandBufferCount = 1; 
         submitInfo.pCommandBuffers = &commandBuffer; 

         vkQueueSubmit(VulkanContext::getInstance()->
            getDevice()->graphicsQueue, 1, &submitInfo, 
            VK_NULL_HANDLE); 

         vkQueueWaitIdle(VulkanContext::getInstance()->
            getDevice()->graphicsQueue); 

         vkFreeCommandBuffers(VulkanContext::getInstance()->
            getDevice()->logicalDevice, commandPool, 1, 
            &commandBuffer); 

   } 

我们已经探讨了如何创建和销毁命令缓冲区。如果您有任何疑问,可以参考第十一章,准备清屏。接下来,在Vktools.h文件中,我们将添加复制缓冲区的功能。在命名空间下添加一个新函数:

   VkCommandBuffer beginSingleTimeCommands(VkCommandPool commandPool); 
   void endSingleTimeCommands(VkCommandBuffer commandBuffer, 
      VkCommandPool commandPool); 

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

copyBuffer函数接受源缓冲区、目标缓冲区和缓冲区大小作为输入。现在,将此新函数添加到VkTools.cpp文件中:

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

QueueFamilyIndices qFamilyIndices = VulkanContext::getInstance()->
   getDevice()->getQueueFamiliesIndicesOfCurrentDevice(); 

   // Create Command Pool 
   VkCommandPool commandPool; 

   VkCommandPoolCreateInfo cpInfo = {}; 

   cpInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; 
   cpInfo.queueFamilyIndex = qFamilyIndices.graphicsFamily; 
   cpInfo.flags = 0; 

if (vkCreateCommandPool(VulkanContext::getInstance()->
   getDevice()->logicalDevice, &cpInfo, nullptr, &commandPool) != 
   VK_SUCCESS) { 
         throw std::runtime_error(" failed to create 
            command pool !!"); 
   } 

   // Allocate command buffer and start recording 
   VkCommandBuffer commandBuffer = beginSingleTimeCommands(commandPool); 

   //-- Copy the buffer 
   VkBufferCopy copyregion = {}; 
   copyregion.srcOffset = 0; 
   copyregion.dstOffset = 0; 
   copyregion.size = size; 
   vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer,
      1, &copyregion); 

   // End recording and Execute command buffer and free command buffer 
   endSingleTimeCommands(commandBuffer, commandPool); 

   vkDestroyCommandPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, commandPool, 
      nullptr); 

} 

在该函数中,我们首先从设备获取队列家族索引。然后我们创建一个新的命令池,然后使用beginSingleTimeCommands函数创建一个新的命令缓冲区。为了复制缓冲区,我们创建VkBufferCopy结构。我们将源和目标偏移量设置为0并设置缓冲区大小。

要实际复制缓冲区,我们调用vlCmdCopyBuffer函数,它接受一个命令缓冲区、源命令缓冲区、目标命令缓冲区、复制区域数量(在这种情况下为1)和复制区域结构。一旦缓冲区被复制,我们调用endSingleTimeCommands来销毁命令缓冲区,并调用vkDestroyCommandPool来销毁命令池本身。

现在,我们可以回到ObjectsBuffers中的createVertexBuffers函数,并将阶段缓冲区复制到顶点缓冲区。我们还会销毁阶段缓冲区和缓冲区内存:

   vkTools::copyBuffer(stagingBuffer, 
         vertexBuffer, 
         bufferSize); 

   vkDestroyBuffer(VulkanContext::getInstance()->
      getDevice()->logicalDevice, stagingBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, stagingBufferMemory, nullptr); 

索引缓冲区的创建方式相同,使用createIndexBuffer函数:


void ObjectBuffers::createIndexBuffer() { 

   VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size(); 

   VkBuffer stagingBuffer; 
   VkDeviceMemory stagingBufferMemory; 

   vkTools::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(VulkanContext::getInstance()->
     getDevice()->logicalDevice, stagingBufferMemory, 
     0, bufferSize, 0, &data); 
   memcpy(data, indices.data(), (size_t)bufferSize); 
   vkUnmapMemory(VulkanContext::getInstance()->
     getDevice()->logicalDevice, stagingBufferMemory); 

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

   vkTools::copyBuffer(stagingBuffer, 
         indexBuffer, 
         bufferSize); 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, 
     stagingBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, 
      stagingBufferMemory, nullptr); 

}    

创建UniformBuffer比较简单,因为我们只需使用HOST_VISIBLE GPU 内存,因此不需要阶段缓冲区:

void ObjectBuffers::createUniformBuffers() { 

   VkDeviceSize bufferSize = sizeof(UniformBufferObject); 

   vkTools::createBuffer(bufferSize, 
               VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, 
               VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | 
               VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, 
               uniformBuffers, uniformBuffersMemory); 

} 

最后,我们在destroy函数中销毁缓冲区和内存:

void ObjectBuffers::destroy(){ 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, uniformBuffers, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, uniformBuffersMemory, 
      nullptr); 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, indexBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
      getDevice()->logicalDevice, indexBufferMemory, 
      nullptr); 

   vkDestroyBuffer(VulkanContext::getInstance()->
     getDevice()->logicalDevice, vertexBuffer, nullptr); 
   vkFreeMemory(VulkanContext::getInstance()->
     getDevice()->logicalDevice, vertexBufferMemory, nullptr); 

} 

创建描述符类

与 OpenGL 不同,在 OpenGL 中我们使用统一缓冲区来传递模型、视图、投影和其他类型的数据,而 Vulkan 使用描述符。在描述符中,我们必须首先指定缓冲区的布局,以及绑定位置、计数、描述符类型以及与之关联的着色器阶段。

一旦使用不同类型的描述符创建了描述符布局,我们必须为数字交换链图像计数创建一个描述符池,因为统一缓冲区将每帧设置一次。

之后,我们可以为两个帧分配和填充描述符集。数据分配将从池中完成。

我们将创建一个新的类来创建描述符集、布局绑定、池以及分配和填充描述符集。创建一个名为Descriptor的新类。在Descriptor.h文件中,添加以下代码:

#pragma once 
#include <vulkan\vulkan.h> 
#include <vector> 

class Descriptor 
{ 
public: 
   Descriptor(); 
   ~Descriptor(); 

   // all the descriptor bindings are combined into a single layout 

   VkDescriptorSetLayout descriptorSetLayout;  
   VkDescriptorPool descriptorPool; 
   VkDescriptorSet descriptorSet; 

   void createDescriptorLayoutSetPoolAndAllocate(uint32_t 
      _swapChainImageCount); 
   void populateDescriptorSets(uint32_t _swapChainImageCount, 
      VkBuffer uniformBuffers); 

   void destroy(); 

private: 

   void createDescriptorSetLayout(); 
   void createDescriptorPoolAndAllocateSets(uint32_t 
      _swapChainImageCount); 

};  

我们包含常用的Vulkan.h和 vector。在公共部分,我们使用构造函数和析构函数创建类。我们还创建了三个变量,分别称为descriptorSetLayoutdescriptorPooldescriptorSets,它们分别对应于VkDescriptorSetLayoutVkDescriptorPoolVkDescriptorSet类型,以便于访问集合。createDescriptorLayoutSetPoolAndAllocate函数将调用私有的createDescriptorSetLayoutcreateDescriptorPoolAndAllocateSets函数,这些函数将创建布局集,然后创建描述符池并将其分配。当我们将统一缓冲区设置为填充集合中的数据时,将调用populateDescriptorSets函数。

我们还有一个destroy函数来销毁已创建的 Vulkan 对象。在Descriptor.cpp文件中,我们将添加函数的实现。首先添加必要的包含,然后添加构造函数、析构函数和createDescriptorLayoutAndPool函数:

#include "Descriptor.h" 

#include<array> 
#include "VulkanContext.h" 

#include "Mesh.h" 

Descriptor::Descriptor(){ 

} 
Descriptor::~Descriptor(){ 

} 

void Descriptor::createDescriptorLayoutSetPoolAndAllocate(uint32_t 
    _swapChainImageCount){ 

   createDescriptorSetLayout(); 
   createDescriptorPoolAndAllocateSets(_swapChainImageCount); 

} 

createDescriptorLayoutSetPoolAndAllocate函数调用createDescriptorSetLayoutcreateDescriptorPoolAndAllocateSets函数。现在让我们添加createDescriptorSetLayout函数:

void Descriptor::createDescriptorSetLayout() { 

   VkDescriptorSetLayoutBinding uboLayoutBinding = {}; 
   uboLayoutBinding.binding = 0;// binding location 
   uboLayoutBinding.descriptorCount = 1; 
   uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;  
   uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;  

   std::array<VkDescriptorSetLayoutBinding, 1> 
      layoutBindings = { uboLayoutBinding }; 

   VkDescriptorSetLayoutCreateInfo layoutCreateInfo = {}; 
   layoutCreateInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR*SET
*                            LAYOUT_CREATE_INFO; 
   layoutCreateInfo.bindingCount = static_cast<uint32_t> 
                                   (layoutBindings.size()); 
   layoutCreateInfo.pBindings = layoutBindings.data();  

   if (vkCreateDescriptorSetLayout(VulkanContext::getInstance()->
     getDevice()->logicalDevice, &layoutCreateInfo, nullptr, 
     &descriptorSetLayout) != VK_SUCCESS) { 

         throw std::runtime_error("failed to create 
           descriptor set layout"); 
   } 
} 

对于我们的项目,布局集将只有一个布局绑定,即包含模型、视图和投影矩阵信息的那个结构体。

我们必须填充VkDescriptorSetLayout结构体,并指定绑定位置索引、计数、我们将传递的信息类型以及统一缓冲区将被发送到的着色器阶段。在创建集合布局后,我们填充VkDescriptorSetLayoutCreateInfo,在其中指定绑定计数和绑定本身。

然后,我们调用vkCreateDescriptorSetLayout函数,通过传递逻辑设备和布局创建信息来创建描述符集布局。接下来,我们添加createDescriptorPoolAndAllocateSets函数:

void Descriptor::createDescriptorPoolAndAllocateSets(uint32_t 
    _swapChainImageCount) { 

   // create pool 
   std::array<VkDescriptorPoolSize, 1> poolSizes = {}; 

   poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; 
   poolSizes[0].descriptorCount = _swapChainImageCount; 

   VkDescriptorPoolCreateInfo poolInfo = {}; 
   poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; 
   poolInfo.poolSizeCount = static_cast<uint32_t>(poolSizes.size());  
   poolInfo.pPoolSizes = poolSizes.data(); 

   poolInfo.maxSets = _swapChainImageCount;  

   if (vkCreateDescriptorPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &poolInfo, nullptr, 
      &descriptorPool) != VK_SUCCESS) { 

         throw std::runtime_error("failed to create descriptor pool "); 
   } 

   // allocate 
   std::vector<VkDescriptorSetLayout> layouts(_swapChainImageCount, 
       descriptorSetLayout); 

   VkDescriptorSetAllocateInfo allocInfo = {}; 
   allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; 
   allocInfo.descriptorPool = descriptorPool; 
   allocInfo.descriptorSetCount = _swapChainImageCount; 
   allocInfo.pSetLayouts = layouts.data(); 

   if (vkAllocateDescriptorSets(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &allocInfo, &descriptorSet) 
      != VK_SUCCESS) { 

         throw std::runtime_error("failed to allocate descriptor
            sets ! "); 
   }  
}

要创建描述符池,我们必须使用VkDescriptorPoolSize指定池大小。我们创建一个数组并命名为poolSizes。由于在布局集中我们只有统一缓冲区,我们设置其类型并将计数设置为与交换链图像计数相等。要创建描述符池,我们必须指定类型、池大小计数和池大小数据。我们还需要设置maxsets,即可以从池中分配的最大集合数,它等于交换链图像计数。我们通过调用vkCreateDescriptorPool并传递逻辑设备和池创建信息来创建描述符池。接下来,我们必须指定描述符集的分配参数。

我们创建一个描述符集布局的向量。然后,我们创建VkDescriptionAllocationInfo结构体来填充它。我们传递描述符池、描述符集计数(等于交换链图像计数)和布局数据。然后,通过调用vkAllocateDescriptorSets并传递逻辑设备和创建信息结构体来分配描述符集。

最后,我们将添加populateDescriptorSets函数,如下所示:

void Descriptor::populateDescriptorSets(uint32_t _swapChainImageCount, 
   VkBuffer uniformBuffers) { 

   for (size_t i = 0; i < _swapChainImageCount; i++) { 

         // Uniform buffer info 

         VkDescriptorBufferInfo uboBufferDescInfo = {}; 
         uboBufferDescInfo.buffer = uniformBuffers; 
         uboBufferDescInfo.offset = 0; 
         uboBufferDescInfo.range = sizeof(UniformBufferObject); 

         VkWriteDescriptorSet uboDescWrites; 
         uboDescWrites.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; 
         uboDescWrites.pNext = NULL; 
         uboDescWrites.dstSet = descriptorSet; 
         uboDescWrites.dstBinding = 0; // binding index of 0  
         uboDescWrites.dstArrayElement = 0;  
         uboDescWrites.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; 
         uboDescWrites.descriptorCount = 1;  
         uboDescWrites.pBufferInfo = &uboBufferDescInfo; // uniforms 
                                                            buffers 
         uboDescWrites.pImageInfo = nullptr; 
         uboDescWrites.pTexelBufferView = nullptr; 

         std::array<VkWriteDescriptorSet, 1> descWrites = { uboDescWrites}; 

         vkUpdateDescriptorSets(VulkanContext::getInstance()->
           getDevice()->logicalDevice, static_cast<uint32_t>
           (descWrites.size()), descWrites.data(), 0, 
           nullptr); 
   } 

} 

此函数接收交换链图像计数和统一缓冲区作为参数。对于交换链的两个图像,需要通过调用vkUpdateDescriptorSets来更新描述符的配置。此函数接收一个VkWriteDescriptorSet数组。现在,VkWriteDescriptorSet接收一个缓冲区、图像结构体或texelBufferView作为参数。由于我们将使用统一缓冲区,我们必须创建它并传递它。VkdescriptorBufferInfo接收一个缓冲区(将是创建的统一缓冲区),接收一个偏移量(在这种情况下为无),然后接收范围(即缓冲区本身的大小)。

创建完成后,我们可以开始指定VkWriteDescriptorSet。这个函数接收类型、descriptorSet和绑定位置(即第 0 个索引)。它没有数组元素,并接收描述符类型(即统一缓冲区类型);描述符计数为1,我们传递缓冲区信息结构体。对于图像信息和纹理缓冲区视图,我们指定为无,因为它没有被使用。

然后,我们创建一个VkWriteDescriptorSet数组,并将我们创建的统一缓冲区描述符写入信息uboDescWrites添加到其中。通过调用vkUpdateDescriptorSets并传递逻辑设备、描述符写入大小和数据来更新描述符集。这就是populateDescriptorSets函数的全部内容。我们最后添加一个销毁函数,该函数销毁描述符池和描述符集布局。添加函数如下:

void Descriptor::destroy(){ 

   vkDestroyDescriptorPool(VulkanContext::getInstance()->
      getDevice()->logicalDevice, descriptorPool, nullptr); 
   vkDestroyDescriptorSetLayout(VulkanContext::getInstance()->
      getDevice()->logicalDevice, descriptorSetLayout, nullptr); 
} 

创建 SPIR-V 着色器二进制文件

与 OpenGL 不同,OpenGL 接收可读的 GLSL(OpenGL 着色语言)文件作为着色器,而 Vulkan 接收二进制或字节码格式的着色器。所有着色器,无论是顶点、片段还是计算着色器,都必须是字节码格式。

SPIR-V 也非常适合交叉编译,这使得移植着色器文件变得容易得多。如果你有 Direct3D HLSL 着色器代码,它可以编译成 SPIR-V 格式,并可以在 Vulkan 应用程序中使用,这使得将 Direct3D 游戏移植到 Vulkan 变得非常容易。着色器最初是用 GLSL 编写的,我们对 OpenGL 编写它的方式做了一些小的改动。提供了一个编译器,可以将代码从 GLSL 编译成 SPIR-V 格式。编译器包含在 Vulkan SDK 安装中。基本的顶点着色器 GLSL 代码如下:

#version 450 
#extension GL_ARB_separate_shader_objects : enable 

layout (binding = 0) uniform UniformBufferOBject{ 

mat4 model; 
mat4 view; 
mat4 proj; 

} ubo; 

layout(location = 0) in vec3 inPosition; 
layout(location = 1) in vec3 inNormal; 
layout(location = 2) in vec3 inColor; 
layout(location = 3) in vec2 inTexCoord; 

layout(location = 0) out vec3 fragColor; 

void main() { 

    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0); 
    fragColor = inColor; 

}

着色器看起来应该非常熟悉,有一些小的改动。例如,GLSL 版本仍然在顶部指定。在这种情况下,它是 #version 450。但我们还看到了一些新事物,例如 #extension GL_ARB_seperate_shader_objects: enable。这指定了着色器使用的扩展。在这种情况下,需要一个旧的扩展,它基本上允许我们将顶点着色器和片段着色器作为单独的文件使用。扩展需要由**架构评审委员会(ARB)**批准。

除了包含扩展之外,你可能还注意到为所有数据类型指定了位置布局。你可能还注意到,在创建顶点和统一缓冲区时,我们必须指定缓冲区的绑定索引。在 Vulkan 中,没有与 GLgetUniformLocation 相当的函数来获取统一缓冲区的位置索引。这是因为获取位置需要相当多的系统资源。相反,我们指定并硬编码索引的值。统一缓冲区以及输入和输出缓冲区都可以分配一个索引 0,因为它们是不同数据类型。由于统一缓冲区将作为包含模型、视图和投影矩阵的结构体发送,所以在着色器中创建了一个类似的结构体,并将其分配给统一布局的 0^(th) 索引。

所有四个属性也被分配了一个布局索引 0123,正如在 Mesh.h 文件下的 Vertex 结构体中设置 VkVertexInputAttributeDescription 为四个属性时所指定的。输出也被分配了一个布局位置索引 0,并且数据类型被指定为 vec3。然后,在着色器的主函数中,我们通过将对象的局部坐标乘以从统一缓冲区结构体接收到的模型、视图和投影矩阵来设置 gl_Position 值。此外,outColor 被设置为接收到的 inColor

片段着色器如下:打开一个 .txt 文件,将着色器代码添加到其中,并将文件命名为 basic. 然后,将扩展名从 *.txt 改为 *.vert

#version 450 
#extension GL_ARB_separate_shader_objects : enable 

layout(location = 0) in vec3 fragColor; 

layout(location = 0) out vec4 outColor; 

void main() { 

   outColor = vec4(fragColor, 1.0f); 

}

在这里,我们指定了 GLSL 版本和要使用的扩展。有 inout,它们都有一个位置布局为 0。请注意,in 是一个名为 fragColorvec3,这是我们从顶点着色器发送出来的,而 outColor 是一个 vec4。在着色器文件的 main 函数中,我们将 vec3 转换为 vec4,并将结果颜色设置为 outColor。将片段着色器添加到名为 basic.frag 的文件中。在 VulkanProject 根目录下,创建一个名为 shaders 的新文件夹,并将两个着色器文件添加到其中:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/4fdcc29c-5465-47f9-ae6a-9cf9cb4e2a4a.png

创建一个名为 SPIRV 的新文件夹,因为这是我们放置编译好的 SPIRV 字节码文件的地方。要编译 .glsl 文件,我们将使用安装 Vulkan SDK 时安装的 glslValidator.exe 文件。现在,要编译代码,我们可以使用以下命令:

glslangValidator.exe -V basic.frag -o basic.frag.spv 

按住键盘上的 Shift 键,在 shaders 文件夹中右键单击,然后点击在此处打开 PowerShell 窗口:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/7a0d7a83-d6ce-4d54-bf04-4b231e46c0eb.png

在 PowerShell 中,输入以下命令:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/955dd04b-e602-4fb5-8d39-d26bec5707d6.png

确保将 V 大写,将 o 小写,否则将会出现编译错误。这将在文件夹中创建一个新的 spirv 文件。将 frag 改为 vert 以编译 SPIRV 顶点着色器:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/bf23acba-9668-4343-ba9e-8b8d4d506b37.png

这将在文件夹中创建顶点和片段着色器 SPIRV 二进制文件:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/90596c8c-2d1a-4622-8dfa-a4c66b908fbb.png

而不是每次都手动编译代码,我们可以创建一个 .bat 文件来自动完成这项工作,并将编译好的 SPIRV 二进制文件放在 SPIRV 文件夹中。在 shaders 文件夹中,创建一个新的 .txt 文件,并将其命名为 glsl_spirv_compiler.bat

.bat 文件中,添加以下内容:

@echo off 
echo compiling glsl shaders to spirv  
for /r %%i in (*.vert;*.frag) do %VULKAN_SDK%\Bin32\glslangValidator.exe -V "%%i" -o  "%%~dpiSPIRV\%%~nxi".spv 

保存并关闭文件。现在双击 .bat 文件以执行它。这将编译着色器并将编译好的二进制文件放置在 SPIRV 着色器文件中:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/d4acaddc-c373-4a63-934f-4d751d123877.png

您可以使用控制台命令删除 shaders 文件夹中我们之前编译的 SPIRV 文件,因为我们将会使用 SPIRV 子文件夹中的着色器文件。

摘要

在本章中,我们创建了渲染几何形状所需的所有资源。首先,我们添加了 Mesh 类,它包含了所有网格类型(包括三角形、四边形、立方体和球体)的顶点和索引信息。然后,我们创建了 ObjectBuffers 类,它用于存储并将缓冲区绑定到 GPU 内存,使用的是 VkTool 文件。我们还创建了一个单独的描述符类,其中包含我们的描述符集布局和池。此外,我们还创建了描述符集。最后,我们创建了从 GLSL 着色器编译的 SPIRV 字节码着色器文件。

在下一章中,我们将使用这里创建的资源来绘制我们的第一个彩色几何形状。

第十二章:绘制 Vulkan 对象

在上一章中,我们创建了绘制对象所需的所有资源。在本章中,我们将创建 ObjectRenderer 类,该类将在视口中绘制对象。这个类被用来确保我们有一个实际的几何对象来绘制和查看,与我们的紫色视口一起。

我们还将学习如何在章节末尾同步 CPU 和 GPU 操作,这将消除我们在第十一章,创建对象资源中遇到的验证错误。

在我们为渲染设置场景之前,我们必须为几何渲染准备最后一件事;那就是图形管线。我们将在下一节开始设置它。

在本章中,我们将涵盖以下主题:

  • 准备 GraphicsPipeline

  • ObjectRenderer

  • VulkanContext 类的更改

  • Camera

  • 绘制一个对象

  • 同步一个对象

准备 GraphicsPipeline 类

图形管线定义了对象绘制时应遵循的管线。正如我们在第二章,数学和图形概念中发现的,我们需要遵循一系列步骤来绘制对象:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/14e58b29-0939-4abf-a065-4424953587e6.png

在 OpenGL 中,管线状态可以在任何时候更改,就像我们在第八章,通过碰撞、循环和光照增强你的游戏中绘制文本时启用和禁用混合一样。然而,更改状态会占用大量系统资源,这就是为什么 Vulkan 鼓励你不要随意更改状态。因此,你必须为每个对象预先设置管线状态。在你创建管线状态之前,你还需要创建一个管线布局,该布局将使用我们在上一章中创建的描述符集布局。所以,我们首先创建管线布局。

然后,我们还需要提供着色器 SPIR-V 文件,这些文件必须被读取以了解如何创建着色器模块。因此,向类中添加功能。然后我们填充图形管线信息,它将使用我们创建的不同着色器模块。我们还指定了顶点输入状态,它将包含有关我们之前在定义顶点结构时创建的缓冲区绑定和属性的信息。

输入装配状态也需要指定,它描述了将使用顶点绘制的几何类型。请注意,我们可以使用给定的顶点集绘制点、线或三角形。

此外,我们还需要指定视图状态,它描述了将要渲染的帧缓冲区区域,因为如果需要,我们可以将帧缓冲区的一部分显示到视口中。在我们的情况下,我们将显示整个区域到视口中。我们指定了光栅化状态,它将执行深度测试和背面剔除,并将几何形状转换为光栅化线条——这些线条将按照片段着色器中指定的颜色进行着色。

多采样状态将指定是否要启用多采样以启用抗锯齿。深度和模板状态指定是否启用深度和模板测试,并且要在对象上执行。颜色混合状态指定是否启用混合。最后,动态状态使我们能够在不重新创建管线的情况下动态地更改一些管线状态。在我们的实现中,我们不会使用动态状态。设置好所有这些后,我们可以为对象创建图形管线。

让我们先创建一个新的类来处理图形管线。在GraphicsPipeline.h文件中,添加以下内容:

#include <vulkan\vulkan.h> 
#include <vector> 

#include <fstream> 

class GraphicsPipeline 
{ 
public: 
   GraphicsPipeline(); 
   ~GraphicsPipeline(); 

   VkPipelineLayout pipelineLayout; 
   VkPipeline graphicsPipeline; 

   void createGraphicsPipelineLayoutAndPipeline(VkExtent2D 
     swapChainImageExtent, VkDescriptorSetLayout descriptorSetLayout, 
     VkRenderPass renderPass); 

   void destroy(); 

private: 

   std::vector<char> readfile(const std::string& filename); 
   VkShaderModule createShaderModule(const std::vector<char> & code); 

   void createGraphicsPipelineLayout(VkDescriptorSetLayout 
      descriptorSetLayout); 
   void createGraphicsPipeline(VkExtent2D swapChainImageExtent, 
      VkRenderPass renderPass); 

}; 

我们包含了常用的头文件,还包含了fstream,因为我们需要它来读取着色器文件。然后我们创建类本身。在public部分,我们将添加构造函数和析构函数。我们创建了用于存储VkPipelineLayoutVkPipeline类型的pipelineLayoutgraphicsPipeline对象的实例。

我们创建了一个名为createGraphicsPipelineLayoutAndPipeline的新函数,它接受VkExtent2DVkDescriptorSetLayoutVkRenderPass作为参数,因为这是创建布局和实际管线本身所必需的。该函数将内部调用createGraphicsPipelineLayoutcreateGraphicsPipeline,分别创建布局和管线。这些函数被添加到private部分。

public部分,我们还有一个名为destroy的函数,它将销毁所有创建的资源。在private部分,我们还有另外两个函数。第一个是readFile函数,它读取 SPIR-V 文件,第二个是createShaderModule函数,它将从读取的着色器文件创建着色器模块。现在让我们转到GraphicsPipeline.cpp文件:

#include "GraphicsPipeline.h" 

#include "VulkanContext.h" 
#include "Mesh.h" 

GraphicsPipeline::GraphicsPipeline(){} 

GraphicsPipeline::~GraphicsPipeline(){} 

在前面的代码块中,我们包含了GraphicsPipeline.hVulkanContext.hMesh.h文件,因为它们是必需的。我们还添加了构造函数和析构函数的实现。

然后我们添加了createGraphicsPipelineLayoutAndPipeline函数,如下所示:

void GraphicsPipeline::createGraphicsPipelineLayoutAndPipeline(VkExtent2D 
  swapChainImageExtent, VkDescriptorSetLayout descriptorSetLayout, 
  VkRenderPass renderPass){ 

   createGraphicsPipelineLayout(descriptorSetLayout); 
   createGraphicsPipeline(swapChainImageExtent, renderPass); 

} 

createPipelineLayout函数的创建如下。我们必须创建一个createInfo结构体,设置结构类型,设置descriptorLayout和计数,然后使用vkCreatePipelineLayout函数创建管线布局:

void GraphicsPipeline::createGraphicsPipelineLayout(VkDescriptorSetLayout 
  descriptorSetLayout){ 

   VkPipelineLayoutCreateInfo pipelineLayoutInfo = {}; 
   pipelineLayoutInfo.sType = VK_STRUCTURE_TYPEPIPELINE_                              LAYOUT_CREATE_INFO; 

// used for passing uniform objects and images to the shader 

pipelineLayoutInfo.setLayoutCount = 1; 
   pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout; 

   if (vkCreatePipelineLayout(VulkanContext::getInstance()->
      getDevice()->logicalDevice, &pipelineLayoutInfo, nullptr, 
      &pipelineLayout) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create pieline 
            layout !"); 
   } 

} 

在我们添加创建管线函数之前,我们将添加readFilecreateShaderModule函数:

std::vector<char> GraphicsPipeline::readfile(const std::string& filename) { 

   std::ifstream file(filename, std::ios::ate | std::ios::binary); 

   if (!file.is_open()) { 
         throw std::runtime_error(" failed to open shader file"); 
   } 
   size_t filesize = (size_t)file.tellg(); 

   std::vector<char> buffer(filesize); 

   file.seekg(0); 
   file.read(buffer.data(), filesize); 

   file.close(); 

   return buffer; 

}  

readFile 函数接收一个 SPIR-V 代码文件,打开并读取它,将文件内容保存到名为 buffer 的字符向量中,然后返回它。然后我们添加 createShaderModule 函数,如下所示:

VkShaderModule GraphicsPipeline::createShaderModule(const std::vector<char> & code) { 

   VkShaderModuleCreateInfo cInfo = {}; 

   cInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; 
   cInfo.codeSize = code.size(); 
   cInfo.pCode = reinterpret_cast<const uint32_t*>(code.data()); 

   VkShaderModule shaderModule; 
   if (vkCreateShaderModule(VulkanContext::getInstance()->getDevice()->
     logicalDevice, &cInfo, nullptr, &shaderModule) != VK_SUCCESS) { 
     throw std::runtime_error(" failed to create shader module !"); 
   } 

   return shaderModule; 
} 

要创建所需的 ShaderStageCreateInfo 来创建管线,我们需要填充 ShaderModuleCreateInfo,它从缓冲区中获取代码和大小以创建着色器模块。着色器模块是通过 vkCreateShaderModule 函数创建的,它接收设备和 CreateInfo。一旦创建了着色器模块,它就会被返回。要创建管线,我们必须创建以下信息结构体:着色器阶段信息、顶点输入信息、输入装配结构体、视口信息结构体、光栅化信息结构体、多重采样状态结构体、深度模板结构体(如果需要)、颜色混合结构体和动态状态结构体。

因此,让我们依次创建每个结构体,从着色器阶段结构体开始。添加 createGraphicsPipeline 函数,并在其中创建管线:

void GraphicsPipeline::createGraphicsPipeline(VkExtent2D swapChainImageExtent,  VkRenderPass renderPass) { 

... 

} 

在这个函数中,我们现在将添加以下内容,这将创建图形管线。

ShaderStageCreateInfo

要创建顶点着色器 ShaderStageCreateInfo,我们需要首先读取着色器代码并为其创建着色器模块:

   auto vertexShaderCode = readfile("shaders/SPIRV/basic.vert.spv"); 

   VkShaderModule vertexShadeModule = createShaderModule(vertexShaderCode); 

为了读取着色器文件,我们传递着色器文件的路径。然后,我们将读取的代码传递给 createShaderModule 函数,这将给我们 vertexShaderModule。我们为顶点着色器创建着色器阶段信息结构体,并传递阶段、着色器模块以及要在着色器中使用的函数名称,在我们的例子中是 main

   VkPipelineShaderStageCreateInfo vertShaderStageCreateInfo = {}; 
   vertShaderStageCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER
                                     _STAGE_CREATE_INFO; 
   vertShaderStageCreateInfo.stage = VK_SHADER_STAGE_VERTEX_BIT; 
   vertShaderStageCreateInfo.module = vertexShadeModule; 
   vertShaderStageCreateInfo.pName = "main";  

同样,我们将为片段着色器创建 ShaderStageCreateInfo 结构体:

   auto fragmentShaderCode = readfile("shaders/SPIRV/basic.frag.spv"); 
   VkShaderModule fragShaderModule = createShaderModule
                                     (fragmentShaderCode); 

   VkPipelineShaderStageCreateInfo fragShaderStageCreateInfo = {}; 

   fragShaderStageCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINESHADER_                                      STAGE_CREATE_INFO; 
   fragShaderStageCreateInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT; 
   fragShaderStageCreateInfo.module = fragShaderModule; 
   fragShaderStageCreateInfo.pName = "main";

注意,着色器阶段被设置为 VK_SHADER_STAGE_FRAGMENT_BIT 以表明这是一个片段着色器,我们还传递了 basic.frag.spv 作为要读取的文件,这是片段着色器文件。然后我们创建一个 shaderStageCreateInfo 数组,并将两个着色器添加到其中以方便使用:

VkPipelineShaderStageCreateInfo shaderStages[] = {    
    vertShaderStageCreateInfo, fragShaderStageCreateInfo }; 

VertexInputStateCreateInfo

在这个信息中,我们指定了输入缓冲区绑定和属性描述:

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

VkPipelineVertexInputStateCreateInfo vertexInputInfo = {}; 
vertexInputInfo.sType = VK_STRUCTURE_TYPE*PIPELINE
*                           VERTEX_INPUT_STATE_CREATE_INFO;
// initially was 0 as vertex data was hardcoded in the shader 
vertexInputInfo.vertexBindingDescriptionCount = 1; 
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription; 

vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t> 
                                                  (attribiteDescriptions.size());    
vertexInputInfo.pVertexAttributeDescriptions = attribiteDescriptions
                                               .data();

这在 Mesh.h 文件下的顶点结构体中指定。

InputAssemblyStateCreateInfo

在这里,我们指定我们想要创建的几何形状,即三角形列表。添加如下:

   VkPipelineInputAssemblyStateCreateInfo inputAssemblyInfo = {}; 
   inputAssemblyInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*INPUT
*                             ASSEMBLY_STATE_CREATE_INFO; 
   inputAssemblyInfo.topology =  VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; 
   inputAssemblyInfo.primitiveRestartEnable = VK_FALSE; 

RasterizationStateCreateInfo

在这个结构体中,我们指定启用深度裁剪,这意味着如果片段超出了近平面和远平面,不会丢弃这些片段,而是仍然保留该片段的值,并将其设置为近平面或远平面的值,即使该像素超出了这两个平面中的任何一个。

通过将 rasterizerDiscardEnable 的值设置为 true 或 false 来在光栅化阶段丢弃像素。将多边形模式设置为 VK_POLYGON_MODE_FILLVK_POLYGON_MODE_LINE。如果设置为线,则只绘制线框;否则,内部也会被光栅化。

我们可以使用lineWidth参数设置线宽。此外,我们可以启用或禁用背面剔除,然后通过设置cullModefrontFace参数来设置前向面 winding 的顺序。

我们可以通过启用它并添加一个常数到深度,夹断它,或添加一个斜率因子来改变深度值。深度偏差在阴影图中使用,我们不会使用,所以不会启用深度偏差。添加结构体并按如下方式填充它:

   VkPipelineRasterizationStateCreateInfo rastStateCreateInfo = {}; 
   rastStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*RASTERIZATION
*                               STATE_CREATE_INFO; 
   rastStateCreateInfo.depthClampEnable = VK_FALSE; 
   rastStateCreateInfo.rasterizerDiscardEnable = VK_FALSE;  
   rastStateCreateInfo.polygonMode = VK_POLYGON_MODE_FILL; 
   rastStateCreateInfo.lineWidth = 1.0f; 
   rastStateCreateInfo.cullMode = VK_CULL_MODE_BACK_BIT; 
   rastStateCreateInfo.frontFace = VK_FRONT_FACE_CLOCKWISE; 
   rastStateCreateInfo.depthBiasEnable = VK_FALSE; 
   rastStateCreateInfo.depthBiasConstantFactor = 0.0f; 
   rastStateCreateInfo.depthBiasClamp = 0.0f; 
   rastStateCreateInfo.depthBiasSlopeFactor = 0.0f; 

MultisampleStateCreateInfo

对于我们的项目,我们不会启用多采样来抗锯齿。然而,我们仍然需要创建以下结构体:

   VkPipelineMultisampleStateCreateInfo msStateInfo = {}; 
   msStateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*MULTISAMPLE
*                       STATE_CREATE_INFO; 
   msStateInfo.sampleShadingEnable = VK_FALSE; 
   msStateInfo.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

我们通过将sampleShadingEnable设置为 false 并将样本计数设置为1来禁用它。

深度和模板创建信息

由于我们没有深度或模板缓冲区,我们不需要创建它。但是,当你有一个深度缓冲区时,你需要将其添加到使用深度纹理。

ColorBlendStateCreateInfo

我们将颜色混合设置为 false,因为我们的项目不需要。为了填充它,我们必须首先创建ColorBlend附加状态,它包含每个附加中每个ColorBlend的配置。然后,我们创建ColorBlendStateInfo,它包含整体混合状态。

创建ColorBlendAttachment状态如下。在这里,我们仍然指定颜色写入掩码,即红色、绿色、蓝色和 alpha 位,并将附加状态设置为 false,以禁用帧缓冲区附加的混合:

   VkPipelineColorBlendAttachmentState  cbAttach = {}; 
   cbAttach.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | 
                             VK_COLOR_COMPONENT_G_BIT | 
                             VK_COLOR_COMPONENT_B_BIT | 
                             VK_COLOR_COMPONENT_A_BIT; 
   cbAttach.blendEnable = VK_FALSE; 

我们创建实际的混合结构体,它接受创建的混合附加信息,并将附加计数设置为1,因为我们有一个单独的附加:

   cbCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_COLORBLEND_                        STATE_CREATE_INFO; 
   cbCreateInfo.attachmentCount = 1; 
   cbCreateInfo.pAttachments = &cbAttach;  

动态状态信息

由于我们没有任何动态状态,所以没有创建。

ViewportStateCreateInfo

ViewportStateCreateInfo中,我们可以指定输出将被渲染到视口的帧缓冲区区域。因此,我们可以渲染场景,但只显示其中的一部分到视口。我们还可以指定一个裁剪矩形,这将丢弃渲染到视口的像素。

然而,我们不会做任何花哨的事情,因为我们将会以原样渲染整个场景到视口。为了定义视口大小和裁剪大小,我们必须创建相应的结构体,如下所示:

   VkViewport viewport = {}; 
   viewport.x = 0; 
   viewport.y = 0; 
   viewport.width = (float)swapChainImageExtent.width; 
   viewport.height = (float)swapChainImageExtent.height; 
   viewport.minDepth = 0.0f; 
   viewport.maxDepth = 1.0f; 

   VkRect2D scissor = {}; 
   scissor.offset = { 0,0 }; 
   scissor.extent = swapChainImageExtent; 

对于视口大小和范围,我们将它们设置为swapChain图像大小的宽度和高度,从(0, 0)开始。我们还设置了最小和最大深度,通常在01之间。

对于裁剪,由于我们想显示整个视口,我们将偏移设置为(0, 0),这表示我们不想从视口开始的地方开始偏移。相应地,我们将scissor.extent设置为swapChain图像的大小。

现在我们可以创建ViewportStateCreateInfo函数,如下所示:

   VkPipelineViewportStateCreateInfo vpStateInfo = {}; 
   vpStateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE*VIEWPORT
*                       STATE_CREATE_INFO; 
   vpStateInfo.viewportCount = 1; 
   vpStateInfo.pViewports = &viewport; 
   vpStateInfo.scissorCount = 1; 
   vpStateInfo.pScissors = &scissor;  

GraphicsPipelineCreateInfo

要创建图形管线,我们必须创建最终的Info结构体,我们将使用迄今为止创建的Info结构体来填充它。所以,添加如下结构体:

   VkGraphicsPipelineCreateInfo gpInfo = {}; 
   gpInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; 

   gpInfo.stageCount = 2; 
   gpInfo.pStages = shaderStages; 

   gpInfo.pVertexInputState = &vertexInputInfo; 
   gpInfo.pInputAssemblyState = &inputAssemblyInfo; 
   gpInfo.pRasterizationState = &rastStateCreateInfo; 
   gpInfo.pMultisampleState = &msStateInfo; 
   gpInfo.pDepthStencilState = nullptr; 
   gpInfo.pColorBlendState = &cbCreateInfo; 
   gpInfo.pDynamicState = nullptr; 

gpInfo.pViewportState = &vpStateInfo; 

我们还需要传递管线布局,渲染通道,并指定是否有任何子通道:

   gpInfo.layout = pipelineLayout; 
   gpInfo.renderPass = renderPass; 
   gpInfo.subpass = 0; 

现在,我们可以创建管线,如下所示:

  if (vkCreateGraphicsPipelines(VulkanContext::getInstance()->
    getDevice()->logicalDevice, VK_NULL_HANDLE, 1, &gpInfo, nullptr, 
    &graphicsPipeline) != VK_SUCCESS) { 
         throw std::runtime_error("failed to create graphics pipeline !!"); 
   } 

此外,请确保销毁着色器模块,因为它们不再需要:

   vkDestroyShaderModule(VulkanContext::getInstance()->
      getDevice()->logicalDevice, vertexShadeModule, nullptr); 
   vkDestroyShaderModule(VulkanContext::getInstance()->
      getDevice()->logicalDevice, fragShaderModule, nullptr); 

createGraphicsPipeline函数的所有内容到此为止。最后,添加destroy函数,该函数将销毁管线和布局:

 void GraphicsPipeline::destroy(){ 

   vkDestroyPipeline(VulkanContext::getInstance()->
      getDevice()->logicalDevice, graphicsPipeline, nullptr); 
   vkDestroyPipelineLayout(VulkanContext::getInstance()->
      getDevice()->logicalDevice, pipelineLayout, nullptr); 

} 

对象渲染器类

在创建了所有必要的类之后,我们最终可以创建我们的ObjectRenderer类,该类将渲染网格对象到场景中。

让我们创建一个新的类,称为ObjectRenderer。在ObjectRenderer.h中添加以下内容:

#include "GraphicsPipeline.h" 
#include "ObjectBuffers.h" 
#include "Descriptor.h" 

#include "Camera.h" 

class ObjectRenderer 
{ 
public: 
  void createObjectRenderer(MeshType modelType, glm::vec3 _position, 
     glm::vec3 _scale); 

   void updateUniformBuffer(Camera camera); 

   void draw(); 

   void destroy(); 

private: 

   GraphicsPipeline gPipeline; 
   ObjectBuffers objBuffers; 
   Descriptor descriptor; 

   glm::vec3 position; 
   glm::vec3 scale; 

};

我们将包含描述符、管线和对象缓冲区头文件,因为它们对于类是必需的。在类的public部分,我们将添加三个类的对象来定义管线、对象缓冲区和描述符。我们添加四个函数:

  • 第一个函数是createObjectRenderer函数,它接受模型类型、对象需要创建的位置以及对象的比例。

  • 然后,我们有了updateUniformBuffer函数,它将在每一帧更新统一缓冲区并将其传递给着色器。这个函数以相机作为参数,因为它需要获取视图和透视矩阵。因此,还需要包含相机头文件。

  • 然后我们有draw函数,它将被用来绑定管线、顶点、索引和描述符以进行绘制调用。

  • 我们还有一个destroy函数来调用管线、描述符和对象缓冲区的destroy函数。

在对象的Renderer.cpp文件中,添加以下includecreateObjectRenderer函数:

#include "ObjectRenderer.h" 
#include "VulkanContext.h" 
void ObjectRenderer::createObjectRenderer(MeshType modelType, glm::vec3 _position, glm::vec3 _scale){ 

uint32_t swapChainImageCount = VulkanContext::getInstance()->
                               getSwapChain()->swapChainImages.size(); 

VkExtent2D swapChainImageExtent = VulkanContext::getInstance()->
                                  getSwapChain()->swapChainImageExtent; 

   // Create Vertex, Index and Uniforms Buffer; 
   objBuffers.createVertexIndexUniformsBuffers(modelType); 

   // CreateDescriptorSetLayout 
     descriptor.createDescriptorLayoutSetPoolAndAllocate
     (swapChainImageCount); 
     descriptor.populateDescriptorSets(swapChainImageCount,
     objBuffers.uniformBuffers); 

   // CreateGraphicsPipeline 
   gPipeline.createGraphicsPipelineLayoutAndPipeline( 
       swapChainImageExtent, 
       descriptor.descriptorSetLayout, 
       VulkanContext::getInstance()->getRenderpass()->renderPass); 

   position = _position; 
   scale = _scale; 

 } 

我们获取交换缓冲区图像的数量及其范围。然后,我们创建顶点索引和统一缓冲区,创建并填充描述符集布局和集,然后创建图形管线本身。最后,我们设置当前对象的位置和缩放。然后,我们添加updateUniformBuffer函数。为了访问SwapChainRenderPass,我们将对VulkanContext类进行一些修改:

void ObjectRenderer::updateUniformBuffer(Camera camera){ 

   UniformBufferObject ubo = {}; 

   glm::mat4 scaleMatrix = glm::mat4(1.0f); 
   glm::mat4 rotMatrix = glm::mat4(1.0f); 
   glm::mat4 transMatrix = glm::mat4(1.0f); 

   scaleMatrix = glm::scale(glm::mat4(1.0f), scale); 
   transMatrix = glm::translate(glm::mat4(1.0f), position); 

   ubo.model = transMatrix * rotMatrix * scaleMatrix; 

   ubo.view = camera.viewMatrix 

   ubo.proj = camera.getprojectionMatrix 

   ubo.proj[1][1] *= -1; // invert Y, in Opengl it is inverted to
                            begin with 

   void* data; 
   vkMapMemory(VulkanContext::getInstance()->getDevice()->
     logicalDevice, objBuffers.uniformBuffersMemory, 0, 
     sizeof(ubo), 0, &data); 

   memcpy(data, &ubo, sizeof(ubo)); 

   vkUnmapMemory(VulkanContext::getInstance()->getDevice()->
     logicalDevice, objBuffers.uniformBuffersMemory); 

}

在这里,我们创建一个新的UniformBufferObject结构体,称为ubo。为此,初始化平移、旋转和缩放矩阵。然后,我们为缩放和旋转矩阵分配值。然后,我们将缩放、旋转和平移矩阵相乘的结果分配给模型矩阵。从camera类中,我们将视图和投影矩阵分配给ubo.viewubo.proj。然后,我们必须在投影空间中反转y轴,因为在 OpenGL 中,y轴已经反转了。现在,我们将更新的ubo结构体复制到统一缓冲区内存中。

接下来是draw函数:

void ObjectRenderer::draw(){ 

   VkCommandBuffer cBuffer = VulkanContext::getInstance()->
                             getCurrentCommandBuffer(); 

   // Bind the pipeline 
   vkCmdBindPipeline(cBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, 
     gPipeline.graphicsPipeline); 

   // Bind vertex buffer to command buffer 
   VkBuffer vertexBuffers[] = { objBuffers.vertexBuffer }; 
   VkDeviceSize offsets[] = { 0 }; 

   vkCmdBindVertexBuffers(cBuffer, 
         0, // first binding index 
         1, // binding count 
         vertexBuffers, 
         offsets); 

   // Bind index buffer to the command buffer 
   vkCmdBindIndexBuffer(cBuffer, 
         objBuffers.indexBuffer, 
         0, 
         VK_INDEX_TYPE_UINT32); 

   //    Bind uniform buffer using descriptorSets 
   vkCmdBindDescriptorSets(cBuffer, 
         VK_PIPELINE_BIND_POINT_GRAPHICS, 
         gPipeline.pipelineLayout, 
         0, 
         1, 
         &descriptor.descriptorSet, 0, nullptr); 

   vkCmdDrawIndexed(cBuffer, 
         static_cast<uint32_t>(objBuffers.indices.size()), // no of indices 
         1, // instance count -- just the 1 
         0, // first index -- start at 0th index 
         0, // vertex offset -- any offsets to add 
         0);// first instance -- since no instancing, is set to 0  

} 

在我们实际进行绘制调用之前,我们必须绑定图形管道,并通过命令缓冲区传入顶点、索引和描述符。为此,我们获取当前命令缓冲区并通过它传递命令。我们还将修改VulkanContext类以获取对它的访问权限。

我们使用vkCmdDrawIndexed进行绘制调用,其中我们传入当前命令缓冲区、索引大小、实例计数、索引的起始位置(为0)、顶点偏移(再次为0)和第一个索引的位置(为0)。然后,我们添加destroy函数,该函数基本上只是调用管道、描述符和对象缓冲区的destroy函数:

 void ObjectRenderer::destroy() { 

   gPipeline.destroy(); 
   descriptor.destroy(); 
   objBuffers.destroy(); 

} 

VulkanContext 类的更改

为了获取对SwapChainRenderPass和当前命令缓冲区的访问权限,我们将在VulkanContext.h文件中VulkanContext类的public部分添加以下函数:

   void drawBegin(); 
   void drawEnd(); 
   void cleanup(); 

   SwapChain* getSwapChain(); 
   Renderpass* getRenderpass(); 
   VkCommandBuffer getCurrentCommandBuffer();  

然后,在VulkanContext.cpp文件中,添加访问值的实现:

SwapChain * VulkanContext::getSwapChain() { 

   return swapChain; 
} 

Renderpass * VulkanContext::getRenderpass() { 

   return renderPass; 
} 

VkCommandBuffer VulkanContext::getCurrentCommandBuffer() { 

   return curentCommandBuffer; 
} 

摄像机类

我们将创建一个基本的摄像机类,以便我们可以设置摄像机的位置并设置视图和投影矩阵。这个类将与为 OpenGL 项目创建的摄像机类非常相似。camera.h文件如下:

#pragma once 

#define GLM_FORCE_RADIAN 
#include <glm\glm.hpp> 
#include <glm\gtc\matrix_transform.hpp> 

class Camera 
{ 
public: 

   void init(float FOV, float width, float height, float nearplane, 
      float farPlane); 

void setCameraPosition(glm::vec3 position); 
   glm::mat4 getViewMatrix(); 
   glm::mat4 getprojectionMatrix(); 

private: 

   glm::mat4 projectionMatrix; 
   glm::mat4 viewMatrix; 
   glm::vec3 cameraPos; 

};

它有一个init函数,该函数接受视口的FOV、宽度和高度以及近平面和远平面来构建投影矩阵。我们有一个setCameraPosition函数,用于设置摄像机的位置,以及两个getter函数来获取摄像机视图和投影矩阵。在private部分,我们有三个局部变量:两个用于存储投影和视图矩阵,第三个是一个vec3用于存储摄像机的位置。

Camera.cpp文件如下:

#include "Camera.h" 

void Camera::init(float FOV, float width, float height, float nearplane, 
   float farPlane) { 

   cameraPos = glm::vec3(0.0f, 0.0f, 4.0f); 
   glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, 0.0f); 
   glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f); 

   viewMatrix = glm::mat4(1.0f); 
   projectionMatrix = glm::mat4(1.0f); 

   projectionMatrix = glm::perspective(FOV, width / height, nearplane, 
                      farPlane); 
   viewMatrix = glm::lookAt(cameraPos, cameraFront, cameraUp); 

} 

glm::mat4 Camera::getViewMatrix(){ 

   return viewMatrix; 

} 
glm::mat4 Camera::getprojectionMatrix(){ 

   return projectionMatrix; 
} 

void Camera::setCameraPosition(glm::vec3 position){ 

   cameraPos = position; 
} 

init函数中,我们设置视图和投影矩阵,然后添加两个getter函数和setCameraPosition函数。

绘制对象

现在我们已经完成了先决条件,让我们绘制一个三角形:

  1. source.cpp中包含Camera.hObjectRenderer.h
#define GLFW_INCLUDE_VULKAN 
#include<GLFW/glfw3.h> 

#include "VulkanContext.h" 

#include "Camera.h" 
#include "ObjectRenderer.h" 
  1. main函数中,初始化VulkanContext之后,创建一个新的摄像机和一个要渲染的对象,如下所示:
int main() { 

   glfwInit(); 

   glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); 
   glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); 

   GLFWwindow* window = glfwCreateWindow(1280, 720, 
                        "HELLO VULKAN ", nullptr, nullptr); 

   VulkanContext::getInstance()->initVulkan(window); 

   Camera camera; 
   camera.init(45.0f, 1280.0f, 720.0f, 0.1f, 10000.0f); 
   camera.setCameraPosition(glm::vec3(0.0f, 0.0f, 4.0f)); 

   ObjectRenderer object; 
   object.createObjectRenderer(MeshType::kTriangle, 
         glm::vec3(0.0f, 0.0f, 0.0f), 
         glm::vec3(0.5f)); 
  1. while循环中,更新对象的缓冲区并调用object.draw函数:
  while (!glfwWindowShouldClose(window)) { 

         VulkanContext::getInstance()->drawBegin(); 

         object.updateUniformBuffer(camera); 
         object.draw(); 

         VulkanContext::getInstance()->drawEnd(); 

         glfwPollEvents(); 
   }               
  1. 当程序完成时,调用object.destroy函数:
   object.destroy(); 

   VulkanContext::getInstance()->cleanup(); 

   glfwDestroyWindow(window); 
   glfwTerminate(); 

   return 0; 
} 
  1. 运行应用程序并看到一个辉煌的三角形,如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/ab5584e7-f339-4be2-a71b-7576efe4a732.png

哇哦!终于有一个三角形了。嗯,我们还没有完全完成。还记得我们一直遇到的讨厌的验证层错误吗?看看:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/b5fc391a-688c-40f4-bb92-47d328b241e8.png

是时候了解为什么我们会得到这个错误以及它实际上意味着什么。这把我们带到了这本书的最后一个主题:同步。

同步对象

绘图的过程实际上是异步的,这意味着 GPU 可能必须等待 CPU 完成当前工作。例如,使用常量缓冲区,我们向 GPU 发送指令以更新模型视图投影矩阵的每一帧。现在,如果 GPU 不等待 CPU 获取当前帧的统一缓冲区,则对象将无法正确渲染。

为了确保 GPU 仅在 CPU 完成其工作后执行,我们需要同步 CPU 和 GPU。这可以通过两种类型的同步对象来完成:

  • 第一类是栅栏。栅栏是同步 CPU 和 GPU 操作的对象。

  • 我们还有一种同步对象,称为信号量。信号量对象同步 GPU 队列。在我们当前渲染的单个三角形场景中,图形队列提交所有图形命令,然后显示队列获取图像并将其呈现到视口。当然,这同样需要同步;否则,我们将看到尚未完全渲染的场景。

此外还有事件和屏障,它们是用于在命令缓冲区或一系列命令缓冲区内部同步工作的其他类型的同步对象。

由于我们没有使用任何同步对象,Vulkan 验证层正在抛出错误并告诉我们,当我们从 SwapChain 获取图像时,我们需要使用栅栏或信号量来同步它。

VulkanContext.h中的private部分,我们将添加要创建的同步对象,如下所示:

   const int MAX_FRAMES_IN_FLIGHT = 2; 
   VkSemaphore imageAvailableSemaphore;  
   VkSemaphore renderFinishedSemaphore;  
   std::vector<VkFence> inFlightFences;  

我们创建了两个信号量:一个用于在图像可供我们渲染时发出信号,另一个用于在图像渲染完成后发出信号。我们还创建了两个栅栏来同步两个帧。在VulkanContext.cpp中的initVulkan函数下,在创建DrawCommandBuffer对象之后创建Synchronization对象:

   drawComBuffer = new DrawCommandBuffer(); 
   drawComBuffer->createCommandPoolAndBuffer(swapChain->
      swapChainImages.size()); 

   // Synchronization 

   VkSemaphoreCreateInfo semaphoreInfo = {}; 
   semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; 

   vkCreateSemaphore(device->logicalDevice, &semaphoreInfo, 
      nullptr, &imageAvailableSemaphore); 
   vkCreateSemaphore(device->logicalDevice, &semaphoreInfo, 
      nullptr, &renderFinishedSemaphore); 

   inFlightFences.resize(MAX_FRAMES_IN_FLIGHT); 

   VkFenceCreateInfo fenceCreateInfo = {}; 
   fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; 
   fenceCreateInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; 

   for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { 

         if (vkCreateFence(device->logicalDevice, &fenceCreateInfo, 
           nullptr, &inFlightFences[i]) != VK_SUCCESS) { 

         throw std::runtime_error(" failed to create synchronization 
            objects per frame !!"); 
         } 
   } 

我们首先使用semaphoreCreateInfo结构体创建了信号量。我们只需设置结构体类型;我们可以使用vkCreateSemaphore函数创建它,并传递逻辑设备和信息结构体。

接下来,我们创建我们的栅栏。我们将向量的大小调整为正在飞行的帧数,即2。然后,我们创建fenceCreateInfo结构体并设置其类型。现在我们还发出信号,使栅栏准备好渲染。然后,我们使用vkCreateFence创建栅栏,并通过for循环传递逻辑设备和创建栅栏的信息。在DrawBegin函数中,当我们获取图像时,我们将imageAvailable信号量传递给函数,以便当图像可供我们渲染时,信号量将被发出:

   vkAcquireNextImageKHR(device->logicalDevice, 
         swapChain->swapChain, 
         std::numeric_limits<uint64_t>::max(), 
         imageAvailableSemaphore, // is  signaled 
         VK_NULL_HANDLE, 
         &imageIndex); 

一旦图像可供渲染,我们等待栅栏发出信号,以便我们可以开始编写我们的命令缓冲区:

vkWaitForFences(device->logicalDevice, 1, &inFlightFences[imageIndex], 
   VK_TRUE, std::numeric_limits<uint64_t>::max());

我们通过调用 vkWaitForFences 来等待栅栏,并传入逻辑设备、栅栏计数(为 1)以及栅栏本身。然后,我们传入 TRUE 以等待所有栅栏,并传入超时时间。一旦栅栏可用,我们通过调用 vkResetFence 将其设置为未触发状态,然后传入逻辑设备、栅栏计数和栅栏:

  vkResetFences(device->logicalDevice, 1, &inFlightFences[imageIndex]);  

DrawBegin 函数的重置保持不变,这样我们就可以开始记录命令缓冲区。现在,在 DrawEnd 函数中,当提交命令缓冲区的时间到来时,我们设置 imageAvailableSemaphore 的管道阶段以等待,并将 imageAvailableSemaphore 设置为等待。我们将 renderFinishedSemaphore 设置为触发状态。

submitInfo 结构体相应地进行了更改:

   // submit command buffer 
   VkSubmitInfo submitInfo = {}; 
   submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; 
   submitInfo.commandBufferCount = 1; 
   submitInfo.pCommandBuffers = &currentCommandBuffer; 

   // Wait for the stage that writes to color attachment 
   VkPipelineStageFlags waitStages[] = { VK_PIPELINE*STAGE*COLOR_
                                       ATTACHMENT_OUTPUT_BIT }; 
   // Which stage of the pipeline to wait 
   submitInfo.pWaitDstStageMask = waitStages; 

   // Semaphore to wait on before submit command execution begins 
   submitInfo.waitSemaphoreCount = 1; 
   submitInfo.pWaitSemaphores = &imageAvailableSemaphore;   

   // Semaphore to be signaled when command buffers have completed 
   submitInfo.signalSemaphoreCount = 1; 
   submitInfo.pSignalSemaphores = &renderFinishedSemaphore; 

等待的阶段是 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,以便 imageAvailableSemaphore 从未触发状态变为触发状态。这将在颜色缓冲区被写入时触发。然后,我们将 renderFinishedSemaphore 设置为触发状态,以便图像准备好进行展示。提交命令并传入栅栏以显示提交已完成:

vkQueueSubmit(device->graphicsQueue, 1, &submitInfo, inFlightFences[imageIndex]);

一旦提交完成,我们就可以展示图像。在 presentInfo 结构体中,我们将 renderFinishedSemaphore 设置为等待从未触发状态变为触发状态。我们这样做是因为,当信号量被触发时,图像将准备好进行展示:

   // Present frame 
   VkPresentInfoKHR presentInfo = {}; 
   presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; 

   presentInfo.waitSemaphoreCount = 1; 
   presentInfo.pWaitSemaphores = &renderFinishedSemaphore;  

   presentInfo.swapchainCount = 1; 
   presentInfo.pSwapchains = &swapChain->swapChain; 
   presentInfo.pImageIndices = &imageIndex; 

   vkQueuePresentKHR(device->presentQueue, &presentInfo); 
   vkQueueWaitIdle(device->presentQueue);  

VulkanContext 中的 cleanup 函数中,确保销毁信号量和栅栏,如下所示:

 void VulkanContext::cleanup() { 

   vkDeviceWaitIdle(device->logicalDevice); 

   vkDestroySemaphore(device->logicalDevice, 
      renderFinishedSemaphore, nullptr); 
   vkDestroySemaphore(device->logicalDevice, 
      imageAvailableSemaphore, nullptr); 

   // Fences and Semaphores 
   for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { 

        vkDestroyFence(device->logicalDevice, inFlightFences[i], nullptr); 
   } 

   drawComBuffer->destroy(); 
   renderTarget->destroy(); 
   renderPass->destroy(); 
   swapChain->destroy(); 

   device->destroy(); 

   valLayersAndExt->destroy(vInstance->vkInstance, isValidationLayersEnabled); 

   vkDestroySurfaceKHR(vInstance->vkInstance, surface, nullptr);   
   vkDestroyInstance(vInstance->vkInstance, nullptr); 

} 

现在,以调试模式构建并运行应用程序,看看验证层是否停止了抱怨:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/fb29545e-ac23-43a4-968b-a5260e027511.png

现在,通过更改 source.cpp 文件,绘制其他对象,例如四边形、立方体和球体:

#define GLFW_INCLUDE_VULKAN
#include<GLFW/glfw3.h>
#include "VulkanContext.h"
#include "Camera.h"
#include "ObjectRenderer.h"
int main() {

glfwInit();

glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);

GLFWwindow* window = glfwCreateWindow(1280, 720, "HELLO VULKAN ", 
                     nullptr, nullptr);
VulkanContext::getInstance()->initVulkan(window);

Camera camera;
camera.init(45.0f, 1280.0f, 720.0f, 0.1f, 10000.0f);
camera.setCameraPosition(glm::vec3(0.0f, 0.0f, 4.0f));
ObjectRenderer tri;
tri.createObjectRenderer(MeshType::kTriangle,
glm::vec3(-1.0f, 1.0f, 0.0f),
glm::vec3(0.5f));

ObjectRenderer quad;
quad.createObjectRenderer(MeshType::kQuad,
glm::vec3(1.0f, 1.0f, 0.0f),
glm::vec3(0.5f));

ObjectRenderer cube;
cube.createObjectRenderer(MeshType::kCube,
glm::vec3(-1.0f, -1.0f, 0.0f),
glm::vec3(0.5f));

ObjectRenderer sphere;
sphere.createObjectRenderer(MeshType::kSphere,
glm::vec3(1.0f, -1.0f, 0.0f),
glm::vec3(0.5f));

while (!glfwWindowShouldClose(window)) {

VulkanContext::getInstance()->drawBegin();

// updatetri.updateUniformBuffer(camera);
quad.updateUniformBuffer(camera);
cube.updateUniformBuffer(camera);
sphere.updateUniformBuffer(camera);

// draw command
tri.draw();
quad.draw();
cube.draw();
sphere.draw();
VulkanContext::getInstance()->drawEnd();
glfwPollEvents();
}
tri.destroy();
quad.destroy();
cube.destroy();
sphere.destroy();
VulkanContext::getInstance()->cleanup();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}

这应该是最终的输出,其中所有对象都已渲染:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/a298e7f5-a683-46ee-851e-1f49d7253608.png

你还可以添加可以从文件加载的自定义几何形状。

此外,现在你有不同的形状可以渲染,可以添加物理效果并尝试复制在 OpenGL 中制作的物理游戏,并将游戏移植到使用 Vulkan 渲染 API。

此外,代码可以扩展以包括深度缓冲区,向对象添加纹理等。

摘要

因此,这是本书的最终总结。在这本书中,我们从在 Simple and Fast Multimedia Library (SFML) 中创建一个基本游戏开始,该库使用 OpenGL 进行渲染,然后展示了在制作游戏时渲染 API 如何融入整个方案。

我们然后从头开始创建了一个基于物理的完整游戏,使用我们自己的小型游戏引擎。除了使用高级 OpenGL 图形 API 绘制对象外,我们还添加了 bullet 物理引擎来处理游戏物理和游戏对象之间的碰撞检测。我们还添加了一些文本渲染,以便玩家可以看到分数,并且我们还学习了基本的照明,以便为我们的小场景进行照明计算,使场景更加有趣。

最后,我们转向了 Vulkan 渲染 API,这是一个低级图形库。与我们在第三章“设置你的游戏”结束时使用 OpenGL 制作的小游戏相比,在 Vulkan 中,我们到第四章结束时就能渲染基本的几何对象。然而,与 Vulkan 相比,我们能够完全访问 GPU,这给了我们更多的自由度来根据游戏需求定制引擎。

如果你已经走到这一步,那么恭喜你!我希望你喜欢阅读这本书,并且你将继续扩展你对 SFML、OpenGL 和 Vulkan 项目的知识。

祝好运。

进一步阅读

想要了解更多,我强烈推荐访问 Vulkan 教程网站:vulkan-tutorial.com/。该教程还涵盖了如何添加纹理、深度缓冲区、模型加载和米柏映射。本书的代码基于这个教程,因此应该很容易跟随,并有助于你进一步学习书中的 Vulkan 代码库:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/e6b960d7-7c8f-4186-bfd6-c1d914324702.png

Doom 3 Vulkan 渲染器的源代码可在github.com/DustinHLand/vkDOOM3找到——看到实际应用的代码很有趣:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/cpp-gmdev-ex/img/e7c5ce9e-a689-4545-a3db-5e9bfce2cf41.png

我还推荐阅读www.fasterthan.life/blog上的博客,因为它讲述了将 Doom 3 OpenGL 代码移植到 Vulkan 的过程。在这本书中,我们让 Vulkan 负责分配和释放资源。该博客详细介绍了 Vulkan 中内存管理的实现方式。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值