图像

简介

到目前为止,几何图形一直使用逐顶点颜色进行着色,这是一种相当有限的方法。在本教程的这一部分中,我们将实现纹理映射,使几何图形看起来更有趣。这也将允许我们在未来的章节中加载和绘制基本的 3D 模型。

向我们的应用程序添加纹理将涉及以下步骤

  • 创建由设备内存支持的图像对象

  • 使用图像文件中的像素填充它

  • 创建图像采样器

  • 添加组合图像采样器描述符以从纹理采样颜色

我们之前已经使用过图像对象,但这些对象是由交换链扩展自动创建的。这次我们将不得不自己创建一个。创建图像并用数据填充它类似于创建顶点缓冲。我们将首先创建一个暂存资源并用像素数据填充它,然后我们将其复制到我们将用于渲染的最终图像对象。虽然可以为此目的创建一个暂存图像,但 Vulkan 还允许您将像素从 VkBuffer 复制到图像,并且此 API 在某些硬件上实际上速度更快。我们将首先创建此缓冲区并用像素值填充它,然后我们将创建一个图像以将像素复制到该图像。创建图像与创建缓冲区没有太大区别。它涉及查询内存需求、分配设备内存并将其绑定,就像我们之前看到的那样。

但是,在使用图像时,我们需要注意一些额外的事情。图像可以具有不同的布局,这些布局会影响像素在内存中的组织方式。例如,由于图形硬件的工作方式,简单地逐行存储像素可能不会带来最佳性能。对图像执行任何操作时,必须确保它们具有最适合在该操作中使用的布局。当我们指定渲染通道时,我们实际上已经看到了一些这些布局

  • VK_IMAGE_LAYOUT_PRESENT_SRC_KHR:最适合呈现

  • VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:最适合作为附件,用于从片段着色器写入颜色

  • VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL:最适合在传输操作中作为源,例如 vkCmdCopyImageToBuffer

  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL:最适合在传输操作中作为目标,例如 vkCmdCopyBufferToImage

  • VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:最适合从着色器采样

转换图像布局的最常见方法之一是管线屏障。管线屏障主要用于同步对资源的访问,例如确保在读取图像之前先写入图像,但它们也可以用于转换布局。在本章中,我们将看到如何将管线屏障用于此目的。当使用 VK_SHARING_MODE_EXCLUSIVE 时,屏障还可以用于传输队列族的所有权。

图像库

有许多库可用于加载图像,您甚至可以编写自己的代码来加载简单的格式,如 BMP 和 PPM。在本教程中,我们将使用 stb 集合中的 stb_image 库。它的优点是所有代码都在一个文件中,因此不需要任何复杂的构建配置。下载 stb_image.h 并将其存储在方便的位置,例如保存 GLFW 和 GLM 的目录。将该位置添加到您的 include 路径。

Visual Studio

将包含 stb_image.h 的目录添加到 Additional Include Directories 路径中。

include dirs stb

Makefile

将包含 stb_image.h 的目录添加到 GCC 的 include 目录中

VULKAN_SDK_PATH = /home/user/VulkanSDK/x.x.x.x/x86_64
STB_INCLUDE_PATH = /home/user/libraries/stb

...

CFLAGS = -std=c++17 -I$(VULKAN_SDK_PATH)/include -I$(STB_INCLUDE_PATH)

加载图像

像这样包含图像库

#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>

默认情况下,头文件仅定义函数的原型。一个代码文件需要包含带有 STB_IMAGE_IMPLEMENTATION 定义的头文件以包含函数体,否则我们将得到链接错误。

void initVulkan() {
    ...
    createCommandPool();
    createTextureImage();
    createVertexBuffer();
    ...
}

...

void createTextureImage() {

}

创建一个新函数 createTextureImage,我们将在其中加载图像并将其上传到 Vulkan 图像对象中。我们将使用命令缓冲,因此应在 createCommandPool 之后调用它。

shaders 目录旁边创建一个新的 textures 目录,以在其中存储纹理图像。我们将从该目录中加载名为 texture.jpg 的图像。我选择使用以下 CC0 许可图像调整大小为 512 x 512 像素,但您可以随意选择任何想要的图像。该库支持大多数常见的图像文件格式,例如 JPEG、PNG、BMP 和 GIF。

texture

使用此库加载图像非常容易

void createTextureImage() {
    int texWidth, texHeight, texChannels;
    stbi_uc* pixels = stbi_load("textures/texture.jpg", &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
    VkDeviceSize imageSize = texWidth * texHeight * 4;

    if (!pixels) {
        throw std::runtime_error("failed to load texture image!");
    }
}

stbi_load 函数将文件路径和要加载的通道数作为参数。即使图像没有 alpha 通道,STBI_rgb_alpha 值也会强制加载带有 alpha 通道的图像,这对于将来与其他纹理保持一致性非常有用。中间三个参数是图像的宽度、高度和实际通道数的输出。返回的指针是像素值数组中的第一个元素。在 STBI_rgb_alpha 的情况下,像素逐行排列,每个像素 4 个字节,总共 texWidth * texHeight * 4 个值。

暂存缓冲

我们现在将在主机可见内存中创建一个缓冲区,以便我们可以使用 vkMapMemory 并将像素复制到其中。将此临时缓冲区的变量添加到 createTextureImage 函数中

VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;

缓冲区应该位于主机可见的内存中,以便我们可以映射它,并且它应该可以用作传输源,以便我们可以稍后将其复制到图像。

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

然后,我们可以将从图像加载库获取的像素值直接复制到缓冲区。

void* data;
vkMapMemory(device, stagingBufferMemory, 0, imageSize, 0, &data);
    memcpy(data, pixels, static_cast<size_t>(imageSize));
vkUnmapMemory(device, stagingBufferMemory);

现在不要忘记清理原始像素数组。

stbi_image_free(pixels);

纹理图像

尽管我们可以设置着色器来访问缓冲区中的像素值,但最好为此目的在 Vulkan 中使用图像对象。图像对象将使我们更容易、更快速地检索颜色,例如,允许我们使用 2D 坐标。图像对象中的像素称为纹素,我们将从现在开始使用这个名称。添加以下新的类成员

VkImage textureImage;
VkDeviceMemory textureImageMemory;

图像的参数在 VkImageCreateInfo 结构体中指定

VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.extent.width = static_cast<uint32_t>(texWidth);
imageInfo.extent.height = static_cast<uint32_t>(texHeight);
imageInfo.extent.depth = 1;
imageInfo.mipLevels = 1;
imageInfo.arrayLayers = 1;

imageType 字段中指定的图像类型告诉 Vulkan 图像中的纹素将使用哪种坐标系进行寻址。可以创建 1D、2D 和 3D 图像。一维图像可用于存储数据或渐变数组,二维图像主要用于纹理,三维图像可用于存储体素体积等。extent 字段指定图像的尺寸,基本上每个轴上有多少纹素。这就是为什么 depth 必须为 1 而不是 0 的原因。我们的纹理不会是数组,并且我们现在不会使用 mipmapping。

imageInfo.format = VK_FORMAT_R8G8B8A8_SRGB;

Vulkan 支持许多可能的图像格式,但我们应该为纹素使用与缓冲区中像素相同的格式,否则复制操作将失败。

imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;

tiling 字段可以有两个值之一

  • VK_IMAGE_TILING_LINEAR:纹素按行主序排列,就像我们的 pixels 数组一样

  • VK_IMAGE_TILING_OPTIMAL:纹素以实现定义的顺序排列,以实现最佳访问

与图像的布局不同,平铺模式不能在以后更改。如果要直接访问图像内存中的纹素,则必须使用 VK_IMAGE_TILING_LINEAR。我们将使用暂存缓冲区而不是暂存图像,因此这不是必需的。我们将使用 VK_IMAGE_TILING_OPTIMAL 以便从着色器进行高效访问。

imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;

图像的 initialLayout 只有两个可能的值

  • VK_IMAGE_LAYOUT_UNDEFINED:GPU 不可用,并且第一次转换将丢弃纹素。

  • VK_IMAGE_LAYOUT_PREINITIALIZED:GPU 不可用,但第一次转换将保留纹素。

在第一次转换期间需要保留纹素的情况很少。但是,一个例子是,如果您想将图像与 VK_IMAGE_TILING_LINEAR 布局结合使用作为暂存图像。在这种情况下,您希望将纹素数据上传到其中,然后转换图像使其成为传输源,而不会丢失数据。然而,在我们的例子中,我们首先要将图像转换为传输目标,然后从缓冲区对象将纹素数据复制到其中,因此我们不需要此属性,并且可以安全地使用 VK_IMAGE_LAYOUT_UNDEFINED

imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;

usage 字段的语义与缓冲区创建期间的语义相同。该图像将用作缓冲区复制的目标,因此应将其设置为传输目标。我们还希望能够从着色器访问图像以对我们的网格进行着色,因此用法应包括 VK_IMAGE_USAGE_SAMPLED_BIT

imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

该图像将仅由一个队列族使用:支持图形(因此也支持)传输操作的队列族。

imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.flags = 0; // Optional

samples 标志与多重采样有关。这仅与将用作附件的图像相关,因此请坚持使用一个样本。有一些与稀疏图像相关的可选图像标志。稀疏图像是指只有某些区域实际由内存支持的图像。例如,如果您为体素地形使用 3D 纹理,则可以使用它来避免分配内存来存储大量的“空气”值。我们不会在本教程中使用它,因此将其保留为其默认值 0

if (vkCreateImage(device, &imageInfo, nullptr, &textureImage) != VK_SUCCESS) {
    throw std::runtime_error("failed to create image!");
}

使用 vkCreateImage 创建图像,它没有任何特别值得注意的参数。图形硬件可能不支持 VK_FORMAT_R8G8B8A8_SRGB 格式。您应该有一个可接受的替代方案列表,并选择最受支持的一个。但是,对这种特定格式的支持如此广泛,我们将跳过此步骤。使用不同的格式还需要烦人的转换。我们将在深度缓冲区章节中回到这个问题,在那里我们将实现这样一个系统。

VkMemoryRequirements memRequirements;
vkGetImageMemoryRequirements(device, textureImage, &memRequirements);

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

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

vkBindImageMemory(device, textureImage, textureImageMemory, 0);

为图像分配内存的工作方式与为缓冲区分配内存完全相同。使用 vkGetImageMemoryRequirements 而不是 vkGetBufferMemoryRequirements,并使用 vkBindImageMemory 而不是 vkBindBufferMemory

此函数已经变得相当大,并且在以后的章节中需要创建更多图像,因此我们应该像对缓冲区一样将图像创建抽象为 createImage 函数。创建该函数并将图像对象的创建和内存分配移动到其中

void createImage(uint32_t width, uint32_t height, VkFormat format, VkImageTiling tiling, VkImageUsageFlags usage, VkMemoryPropertyFlags properties, VkImage& image, VkDeviceMemory& imageMemory) {
    VkImageCreateInfo imageInfo{};
    imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
    imageInfo.imageType = VK_IMAGE_TYPE_2D;
    imageInfo.extent.width = width;
    imageInfo.extent.height = height;
    imageInfo.extent.depth = 1;
    imageInfo.mipLevels = 1;
    imageInfo.arrayLayers = 1;
    imageInfo.format = format;
    imageInfo.tiling = tiling;
    imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
    imageInfo.usage = usage;
    imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
    imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateImage(device, &imageInfo, nullptr, &image) != VK_SUCCESS) {
        throw std::runtime_error("failed to create image!");
    }

    VkMemoryRequirements memRequirements;
    vkGetImageMemoryRequirements(device, image, &memRequirements);

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

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

    vkBindImageMemory(device, image, imageMemory, 0);
}

我已经将宽度、高度、格式、平铺模式、用法和内存属性设置为参数,因为这些参数在整个教程中我们将创建的图像之间都会有所不同。

createTextureImage 函数现在可以简化为

void createTextureImage() {
    int texWidth, texHeight, texChannels;
    stbi_uc* pixels = stbi_load("textures/texture.jpg", &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
    VkDeviceSize imageSize = texWidth * texHeight * 4;

    if (!pixels) {
        throw std::runtime_error("failed to load texture image!");
    }

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, imageSize, 0, &data);
        memcpy(data, pixels, static_cast<size_t>(imageSize));
    vkUnmapMemory(device, stagingBufferMemory);

    stbi_image_free(pixels);

    createImage(texWidth, texHeight, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);
}

布局转换

我们现在要编写的函数涉及再次记录和执行命令缓冲区,所以现在是将该逻辑移动到一个或两个辅助函数中的好时机

VkCommandBuffer beginSingleTimeCommands() {
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

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

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

    vkBeginCommandBuffer(commandBuffer, &beginInfo);

    return commandBuffer;
}

void endSingleTimeCommands(VkCommandBuffer commandBuffer) {
    vkEndCommandBuffer(commandBuffer);

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

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

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

这些函数的代码基于 copyBuffer 中的现有代码。您现在可以将该函数简化为

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

    VkBufferCopy copyRegion{};
    copyRegion.size = size;
    vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

    endSingleTimeCommands(commandBuffer);
}

如果我们仍然使用缓冲区,那么我们现在可以编写一个函数来记录和执行 vkCmdCopyBufferToImage 以完成作业,但是此命令首先要求图像处于正确的布局中。创建一个新函数来处理布局转换

void transitionImageLayout(VkImage image, VkFormat format, VkImageLayout oldLayout, VkImageLayout newLayout) {
    VkCommandBuffer commandBuffer = beginSingleTimeCommands();

    endSingleTimeCommands(commandBuffer);
}

执行布局转换最常见的方法之一是使用图像内存屏障。这样的管道屏障通常用于同步对资源的访问,例如确保对缓冲区的写入在读取之前完成,但是当使用 VK_SHARING_MODE_EXCLUSIVE 时,它也可以用于转换图像布局和传输队列族所有权。有一个等效的缓冲区内存屏障可以对缓冲区执行此操作。

VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout = oldLayout;
barrier.newLayout = newLayout;

前两个字段指定布局转换。如果不在乎图像的现有内容,则可以使用 VK_IMAGE_LAYOUT_UNDEFINED 作为 oldLayout

barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;

如果使用屏障来传输队列族所有权,则这两个字段应为队列族的索引。如果您不想执行此操作(不是默认值!),则必须将其设置为 VK_QUEUE_FAMILY_IGNORED

barrier.image = image;
barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
barrier.subresourceRange.baseMipLevel = 0;
barrier.subresourceRange.levelCount = 1;
barrier.subresourceRange.baseArrayLayer = 0;
barrier.subresourceRange.layerCount = 1;

imagesubresourceRange 指定受影响的图像和图像的特定部分。我们的图像不是数组,也没有 mipmapping 级别,因此仅指定一个级别和图层。

barrier.srcAccessMask = 0; // TODO
barrier.dstAccessMask = 0; // TODO

屏障主要用于同步目的,因此您必须指定在屏障之前必须发生哪些涉及资源的操作类型,以及哪些涉及资源的操作必须等待屏障。尽管我们已经使用 vkQueueWaitIdle 手动同步,但我们需要这样做。正确的值取决于旧的和新的布局,因此一旦我们确定要使用哪些转换,我们将回到这个问题。

vkCmdPipelineBarrier(
    commandBuffer,
    0 /* TODO */, 0 /* TODO */,
    0,
    0, nullptr,
    0, nullptr,
    1, &barrier
);

所有类型的管线屏障都使用同一个函数提交。命令缓冲区之后的第一个参数指定在哪个管线阶段发生应在屏障之前发生的操作。第二个参数指定在哪个管线阶段操作将等待屏障。您允许在屏障之前和之后指定的管线阶段取决于您在屏障之前和之后如何使用资源。允许的值在规范的此表中列出。例如,如果您在屏障之后要从 uniform 读取,您将指定一个 VK_ACCESS_UNIFORM_READ_BIT 的使用,并将最早从 uniform 读取的着色器作为管线阶段,例如 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT。为此类型的使用指定非着色器管线阶段是没有意义的,并且当您指定与使用类型不匹配的管线阶段时,验证层会发出警告。

第三个参数是 0VK_DEPENDENCY_BY_REGION_BIT。后者将屏障转换为按区域条件。这意味着允许实现已经开始从已写入的资源部分读取,例如。

最后三对参数引用三种可用类型的管线屏障数组:内存屏障、缓冲区内存屏障和图像内存屏障,就像我们在这里使用的那样。请注意,我们尚未使用 VkFormat 参数,但我们将在深度缓冲区章节中使用该参数进行特殊转换。

将缓冲区复制到图像

在我们回到 createTextureImage 之前,我们将编写另一个辅助函数:copyBufferToImage

void copyBufferToImage(VkBuffer buffer, VkImage image, uint32_t width, uint32_t height) {
    VkCommandBuffer commandBuffer = beginSingleTimeCommands();

    endSingleTimeCommands(commandBuffer);
}

与缓冲区复制一样,您需要指定要将缓冲区的哪一部分复制到图像的哪一部分。这是通过 VkBufferImageCopy 结构完成的

VkBufferImageCopy region{};
region.bufferOffset = 0;
region.bufferRowLength = 0;
region.bufferImageHeight = 0;

region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
region.imageSubresource.mipLevel = 0;
region.imageSubresource.baseArrayLayer = 0;
region.imageSubresource.layerCount = 1;

region.imageOffset = {0, 0, 0};
region.imageExtent = {
    width,
    height,
    1
};

这些字段大多是不言自明的。 bufferOffset 指定缓冲区中像素值开始处的字节偏移量。 bufferRowLengthbufferImageHeight 字段指定像素在内存中的布局方式。例如,图像的行之间可能有一些填充字节。为两者指定 0 表示像素只是紧密排列的,就像我们这里的情况一样。 imageSubresourceimageOffsetimageExtent 字段指示我们希望将像素复制到图像的哪一部分。

使用 vkCmdCopyBufferToImage 函数将缓冲区到图像的复制操作排队

vkCmdCopyBufferToImage(
    commandBuffer,
    buffer,
    image,
    VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
    1,
    &region
);

第四个参数指示图像当前使用的布局。我假设这里图像已经转换为最适合将像素复制到的布局。现在我们只将一小块像素复制到整个图像,但可以指定一个 VkBufferImageCopy 数组,以在一个操作中将来自此缓冲区的许多不同复制执行到图像。

准备纹理图像

我们现在拥有完成纹理图像设置所需的所有工具,因此我们将返回到 createTextureImage 函数。我们上次在那里做的是创建纹理图像。下一步是将暂存缓冲区复制到纹理图像。这涉及两个步骤

  • 将纹理图像转换为 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL

  • 执行缓冲区到图像的复制操作

使用我们刚刚创建的函数很容易做到这一点

transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));

该图像是使用 VK_IMAGE_LAYOUT_UNDEFINED 布局创建的,因此在转换 textureImage 时应将其指定为旧布局。请记住,我们可以这样做,因为我们不关心在执行复制操作之前的内容。

为了能够开始从着色器中的纹理图像进行采样,我们需要最后一次转换来准备其进行着色器访问

transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);

转换屏障掩码

如果您现在启用了验证层运行应用程序,那么您会看到它抱怨 transitionImageLayout 中的访问掩码和管线阶段无效。我们仍然需要根据转换中的布局来设置这些。

我们需要处理两个转换

  • 未定义 → 传输目标:不需要等待任何事物的传输写入

  • 传输目标 → 着色器读取:着色器读取应等待传输写入,特别是片段着色器中的着色器读取,因为这是我们将使用纹理的地方

这些规则使用以下访问掩码和管线阶段指定

VkPipelineStageFlags sourceStage;
VkPipelineStageFlags destinationStage;

if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
    barrier.srcAccessMask = 0;
    barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;

    sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
    destinationStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
} else if (oldLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL && newLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) {
    barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
    barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;

    sourceStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
    destinationStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
} else {
    throw std::invalid_argument("unsupported layout transition!");
}

vkCmdPipelineBarrier(
    commandBuffer,
    sourceStage, destinationStage,
    0,
    0, nullptr,
    0, nullptr,
    1, &barrier
);

如上述表中所示,传输写入必须发生在管线传输阶段。由于写入不必等待任何内容,您可以为屏障前操作指定一个空访问掩码和最早可能的管线阶段 VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT。应该注意的是,VK_PIPELINE_STAGE_TRANSFER_BIT 不是图形和计算管线中的真实阶段。它更像是一个传输发生的伪阶段。有关更多信息和其他伪阶段的示例,请参阅文档

图像将在同一管线阶段写入,然后由片段着色器读取,这就是为什么我们在片段着色器管线阶段指定着色器读取访问的原因。

如果将来我们需要进行更多转换,那么我们将扩展该函数。应用程序现在应该可以成功运行,尽管当然还没有视觉变化。

需要注意的一件事是,命令缓冲区的提交会导致在开头进行隐式的 VK_ACCESS_HOST_WRITE_BIT 同步。由于 transitionImageLayout 函数执行的命令缓冲区只有一个命令,因此如果您在布局转换中需要 VK_ACCESS_HOST_WRITE_BIT 依赖关系,则可以使用此隐式同步并将 srcAccessMask 设置为 0。是否要明确这样做取决于您,但我个人不太喜欢依赖这些类似于 OpenGL 的“隐藏”操作。

实际上有一种特殊类型的图像布局支持所有操作,即 VK_IMAGE_LAYOUT_GENERAL。当然,它的问题在于,它不一定为任何操作提供最佳性能。它对于某些特殊情况是必需的,例如将图像用作输入和输出,或在图像离开预初始化布局后读取图像。

到目前为止,所有提交命令的辅助函数都设置为通过等待队列变为空闲来同步执行。对于实际应用程序,建议将这些操作组合在一个命令缓冲区中,并异步执行它们以获得更高的吞吐量,特别是 createTextureImage 函数中的转换和复制。尝试通过创建一个辅助函数将命令记录到其中的 setupCommandBuffer,并添加一个 flushSetupCommands 来执行到目前为止已记录的命令来尝试这样做。最好在纹理映射工作后执行此操作,以检查纹理资源是否仍然设置正确。

清理

通过在末尾清理暂存缓冲区及其内存来完成 createTextureImage 函数

    transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);

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

主纹理图像将使用到程序结束

void cleanup() {
    cleanupSwapChain();

    vkDestroyImage(device, textureImage, nullptr);
    vkFreeMemory(device, textureImageMemory, nullptr);

    ...
}

该图像现在包含纹理,但我们仍然需要一种从图形管线访问它的方法。我们将在下一章中对此进行研究。