简介

到目前为止,几何体一直使用逐顶点颜色着色,这是一种相当有限的方法。在本教程的这一部分,我们将实现纹理映射,使几何体看起来更有趣。这也将使我们能够在未来的章节中加载和绘制基本的 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 的目录。将该位置添加到您的包含路径。

Visual Studio

将包含 stb_image.h 的目录添加到“附加包含目录”路径中。

Makefile

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

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。

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

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 函数将文件路径和要加载的通道数作为参数。STBI_rgb_alpha 值强制图像加载 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 字段指定图像的尺寸,基本上每个轴上有多少纹素。这就是为什么深度必须为 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:纹素以实现定义的顺序排列,以获得最佳访问

与图像的布局不同,tiling 模式以后不能更改。如果您希望能够直接访问图像内存中的纹素,则必须使用 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 字段的语义与缓冲区创建期间的语义相同。图像将用作缓冲区复制的目标,因此应将其设置为传输目的地。我们还希望能够从着色器访问图像以对我们的网格进行着色,因此 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);
}

我已将宽度、高度、格式、tiling 模式、usage 和内存属性作为参数,因为这些参数在我们将在本教程中创建的图像之间都会有所不同。

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 的 usage 以及将从 uniform 读取的最早着色器作为管线阶段,例如 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT。为此类型的 usage 指定非着色器管线阶段没有意义,并且当您指定的管线阶段与 usage 类型不匹配时,验证层会警告您。

第三个参数是 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);

    ...
}

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

C++ 代码 / 顶点着色器 / 片段着色器