本文档是对Vulkan Tutorial教程的翻译,非官方翻译,仅供vulkan爱好者参考学习。如果有翻译错误,请留言指出,或者联系占航(hangliebe@163.com)进行修正,感谢。建议英文好的朋友直接读原文档。

关注我的微博主页

2022-02-20 v1.0

纹理映射

图像

简介

到目前为止,几何体一直使用每个顶点的颜色来着色,这是一个相当有限的方法。在本教程的这一部分,我们将实现纹理映射,使几何体看起来更有趣。这也将使我们能够在未来的章节中加载和绘制基本的三维模型。

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

  • 创建一个由设备内存支持的图像对象
  • 用图像文件的像素来填充它
  • 创建一个图像取样器
  • 添加一个组合的图像取样器描述符,从纹理中提取颜色。

我们以前已经使用过图像对象,但那些对象是由交换链扩展自动创建的。这一次我们必须自己创建一个。创建图像并向其填充数据与创建顶点缓冲区类似。我们将首先创建一个暂存资源,并在其中填充像素数据,然后将其复制到最终的图像对象中,我们将使用该对象进行渲染。虽然可以为此目的创建一个暂存图像,但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 collection中的stb_image库。它的优点是所有的代码都在一个文件中,所以它不需要任何棘手的构建配置。下载stb_image.h并把它存放在一个方便的位置,比如你保存GLFW和GLM的目录。把这个位置添加到你的包含路径中。

Visual Studio

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

img

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。

img

用这个库加载图片真的很容易:

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通道,即使它没有,这对将来与其他纹理的一致性很有好处。中间的三个参数是图像的宽度、高度和实际通道数的输出。返回的指针是一个像素值数组中的第一个元素。在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中使用图像对象。图像对象将使我们更容易和更快地检索颜色,因为我们可以使用二维坐标,这是其一。图像对象中的像素被称为texels,从现在开始我们将使用这个名字。添加以下新的类成员:

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图像中的纹理将被处理成什么样的坐标系。可以创建一维、二维和三维图像。一维图像可用于存储数据阵列或梯度,二维图像主要用于纹理,而三维图像可用于存储体素体积,例如。extent字段指定了图像的尺寸,基本上每个轴上有多少个质点。这就是为什么depth必须是1而不是0。我们的纹理将不是一个数组,我们现在不会使用 MIP映射。

imageInfo.format = VK_FORMAT_R8G8B8A8_SRGB;

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

imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;

tiling字段可以是下面两个值之一:

  • vk_image_tiling_linear: 纹理以行为主的顺序排列,就像我们的 “像素”阵列。
  • vk_image_tiling_optimal: 纹理以执行定义的顺序排列,以达到最佳访问效果。

与图像的布局不同,平铺模式不能在以后的时间里改变。如果你希望能够直接访问图像内存中的文本,那么你必须使用VK_IMAGE_TILING_LINEAR。我们将使用一个暂存缓冲区而不是暂存图像,所以这没有必要。我们将使用VK_IMAGE_TILING_OPTIMAL来实现着色器的有效访问。

imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;

图像的 “initialLayout”只有两种可能的值。

  • vk_image_layout_undefined。不能被GPU使用,第一次转换会丢弃texels。
  • vk_image_layout_preinitialized: 不能被GPU使用,但第一次转换将保留纹理。

很少有情况下需要在第一次转换时保留texels。然而,一个例子是,如果你想把一个图像作为与VK_IMAGE_TILING_LINEAR布局相结合的暂存图像。在这种情况下,你会想把文本数据上传到它那里,然后在不丢失数据的情况下将图像过渡到传输源。但在我们的例子中,我们首先要把图像转换为传输目标,然后从缓冲区对象中复制texel数据到它,所以我们不需要这个属性,可以安全地使用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标志与多采样有关。这只与将被用作附件的图像有关,所以坚持使用一个样本。还有一些与稀疏图像有关的可选标志。稀疏图像是指只有某些区域实际被内存支持的图像。比如说,如果你使用三维纹理的体素地形,那么你可以用它来避免分配内存来存储大量的 “空气”值。我们不会在本教程中使用它,所以让它的默认值为 “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);
}

T这些函数的代码是基于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指定受影响的图像和图像的具体部分。我们的图像不是一个数组,也没有MIP映射层,所以只指定了一个level 和layer 。

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

障碍物主要用于同步目的,所以你必须指定哪些涉及资源的操作类型必须在障碍物之前发生,哪些涉及资源的操作必须在障碍物上等待。尽管已经使用vkQueueWaitIdle来手动同步,但我们还是需要这样做。正确的值取决于新旧布局,所以一旦我们想出要使用的转换,我们就会回到这个问题上。

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

所有类型的流水线障碍都使用相同的函数提交。在命令缓冲区之后的第一个参数指定在哪个管道阶段发生的操作应该发生在屏障之前。第二个参数指定在哪个管道阶段发生的操作将在屏障上等待。允许在屏障之前和之后指定的管道阶段取决于你在屏障之前和之后如何使用资源。允许的值列在规范的[此表](https://www.khronos.org/registry/vulkan/specs/1.3-extensions/html/chap7.html#synchronization-access-types-supported)中。例如,如果你要在屏障后从统一体中读取,你会指定VK_ACCESS_UNIFORM_READ_BIT'的用法,并指定最早从统一体中读取的着色器作为管道阶段,例如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 “指定了像素值开始在缓冲区中的字节偏移。bufferRowLength”和 “bufferImageHeight”字段指定像素在内存中的排列方式。例如,你可以在图像的行与行之间有一些填充字节。在这两个字段中指定 0表示像素像我们的情况一样被紧密地排列。imageSubresource, imageOffsetimageExtent字段表示我们要将像素复制到图像的哪个部分。

缓冲区到图像的复制操作是通过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中的访问掩码和管道阶段是无效的。我们仍然需要根据过渡中的布局来设置这些。

有两个过渡我们需要处理。

  • Undefined → transfer destination:传输写入,不需要等待任何东西
  • Transfer destination → shader reading:着色器读取应该等待传输写入,特别是片段着色器中的着色器读取,因为那是我们要使用纹理的地方。

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

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++ code / Vertex shader / Fragment shader

图像视图和采样器

在这一章中,我们将再创建两个资源,这是图形管道对图像进行采样所需要的。第一个资源是我们之前在处理交换链图像时已经见过的,但第二个资源是新的–它与着色器如何从图像中读取纹理有关。

纹理图像视图

我们之前已经看到,在交换链图像和帧缓冲区中,图像是通过图像视图而不是直接访问。我们也需要为纹理图像创建这样一个图像视图。

添加一个类成员,为纹理图像保存一个VkImageView,并创建一个新的函数createTextureImageView,我们将在这里创建它:

VkImageView textureImageView;

...

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

...

void createTextureImageView() {

}

这个函数的代码可以直接基于createImageViews。你唯一要做的两个改动是formatimage

VkImageViewCreateInfo viewInfo{};
viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image = textureImage;
viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format = VK_FORMAT_R8G8B8A8_SRGB;
viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
viewInfo.subresourceRange.baseMipLevel = 0;
viewInfo.subresourceRange.levelCount = 1;
viewInfo.subresourceRange.baseArrayLayer = 0;
viewInfo.subresourceRange.layerCount = 1;

我省略了明确的viewInfo.component初始化,因为VK_COMPONENT_SWIZZLE_IDENTITY无论如何都被定义为0。通过调用vkCreateImageView完成图像视图的创建:

if (vkCreateImageView(device, &viewInfo, nullptr, &textureImageView) != VK_SUCCESS) {
    throw std::runtime_error("failed to create texture image view!");
}

因为很多逻辑与createImageViews重复,你可能希望将其抽象为一个新的createImageView函数:

VkImageView createImageView(VkImage image, VkFormat format) {
    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 = VK_IMAGE_ASPECT_COLOR_BIT;
    viewInfo.subresourceRange.baseMipLevel = 0;
    viewInfo.subresourceRange.levelCount = 1;
    viewInfo.subresourceRange.baseArrayLayer = 0;
    viewInfo.subresourceRange.layerCount = 1;

    VkImageView imageView;
    if (vkCreateImageView(device, &viewInfo, nullptr, &imageView) != VK_SUCCESS) {
        throw std::runtime_error("failed to create texture image view!");
    }

    return imageView;
}

现在,createTextureImageView函数可以简化为:

void createTextureImageView() {
    textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB);
}

createImageViews可以简化为:

void createImageViews() {
    swapChainImageViews.resize(swapChainImages.size());

    for (uint32_t i = 0; i < swapChainImages.size(); i++) {
        swapChainImageViews[i] = createImageView(swapChainImages[i], swapChainImageFormat);
    }
}

请确保在程序结束时销毁图像视图,就在销毁图像本身之前:

void cleanup() {
    cleanupSwapChain();

    vkDestroyImageView(device, textureImageView, nullptr);

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

采样器

着色器有可能直接从图像中读取纹理,但当它们被用作纹理时,这并不是很常见。纹理通常是通过采样器访问的,它将应用滤波和变换来计算被检索的最终颜色。

这些过滤器有助于处理诸如过采样的问题。考虑一个被映射到几何体上的纹理,它的碎片多于纹理。如果你只是在每个片段的纹理坐标中选择最接近的texel,那么你会得到像第一张图片那样的结果。

img

如果你通过线性插值将4个最接近的texel结合起来,那么你会得到一个更平滑的结果,如右图所示。当然你的应用可能有艺术风格的要求,更适合左边的风格(想想Minecraft),但在传统的图形应用中,右边的风格是首选。当从纹理中读取颜色时,采样器对象会自动为你应用这种过滤。

欠采样是一个相反的问题,即你的纹理比片段多。这将导致在对高频图案进行采样时出现伪影,比如在一个尖锐的角度对棋盘纹理进行采样。

img

如左图所示,纹理在远处变成了模糊的一团。解决这个问题的方法是各向异性过滤,它也可以由采样器自动应用。

除了这些滤镜之外,采样器还可以处理变换问题。它决定了当你试图通过它的addressing模式读取图像外的文本时会发生什么。下面的图片显示了一些可能性。

img

我们现在将创建一个函数createTextureSampler来设置这样一个采样器对象。稍后我们将使用这个采样器从着色器的纹理中读取颜色。

void initVulkan() {
    ...
    createTextureImage();
    createTextureImageView();
    createTextureSampler();
    ...
}

...

void createTextureSampler() {

}

采样器是通过VkSamplerCreateInfo结构配置的,该结构指定了它应该应用的所有过滤器和转换。

VkSamplerCreateInfo samplerInfo{};
samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
samplerInfo.magFilter = VK_FILTER_LINEAR;
samplerInfo.minFilter = VK_FILTER_LINEAR;

magFilterminFilter字段指定了如何插值被放大或缩小的文本。放大涉及到上面描述的过度取样问题,缩小涉及到欠取样问题。选择是VK_FILTER_NEARESTVK_FILTER_LINEAR,对应于上面图片中的模式。

samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;

寻址模式可以通过 addressMode 字段指定每个轴。可用的值列在下面。大多数在上面的图片中都有演示。注意,轴被称为U、V和W,而不是X、Y和Z,这是纹理空间坐标的惯例。

  • vk_sampler_address_mode_repeat。当超出图像尺寸时重复纹理。
  • vk_sampler_address_mode_mirrored_repeat。和重复一样,但当超出尺寸时,将坐标倒置以镜像图像。
  • vk_sampler_address_mode_clamp_to_edge: 取最接近坐标的边缘的颜色,超出图像尺寸。
  • vk_sampler_address_mode_mirror_clamp_to_edge: 和钳制边缘一样,但使用与最接近的边缘相反的边缘。
  • vk_sampler_address_mode_clamp_to_border: 当取样超出图像的尺寸时,返回一个纯色。

我们在这里使用哪种寻址模式其实并不重要,因为我们在本教程中不打算在图像之外采样。然而,重复模式可能是最常见的模式,因为它可以用来给地板和墙壁等纹理贴图。

samplerInfo.anisotropyEnable = VK_TRUE;
samplerInfo.maxAnisotropy = ???;

这两个字段指定是否应该使用各向异性过滤。没有理由不使用这个,除非性能是一个问题。maxAnisotropy “字段限制了可用于计算最终颜色的texel样本量。一个较低的值会带来更好的性能,但质量较低。为了弄清我们可以使用哪个值,我们需要像这样检索物理设备的属性:

VkPhysicalDeviceProperties properties{};
vkGetPhysicalDeviceProperties(physicalDevice, &properties);

如果你看一下VkPhysicalDeviceProperties结构的文档,你会发现它包含一个VkPhysicalDeviceLimits成员,名为limits。这个结构又有一个叫做 “maxSamplerAnisotropy”的成员,这是我们可以为 “maxAnisotropy”指定的最大数值。如果我们想追求最高质量,我们可以直接使用这个值:

samplerInfo.maxAnisotropy = properties.limits.maxSamplerAnisotropy;

你可以在程序开始时查询这些属性,然后把它们传递给需要它们的函数,或者在createTextureSampler函数本身查询它们。

samplerInfo.borderColor = VK_BORDER_COLOR_INT_OPAQUE_BLACK;

borderColor 字段指定了当用钳制边界寻址模式在图像之外取样时返回哪种颜色。可以返回黑色、白色或透明的浮点数或英寸格式。你不能指定一个任意的颜色。

samplerInfo.unnormalizedCoordinates = VK_FALSE;

unnormalizedCoordinates字段指定了你想用哪种坐标系统来处理图像中的纹理。如果这个字段是VK_TRUE,那么你可以简单地使用[0, texWidth)[0, texHeight)范围内的坐标。如果它是VK_FALSE,那么在所有轴上都使用[0, 1)范围来处理texels。现实世界的应用几乎总是使用归一化的坐标,因为这样就有可能用完全相同的坐标来使用不同分辨率的纹理。

samplerInfo.compareEnable = VK_FALSE;
samplerInfo.compareOp = VK_COMPARE_OP_ALWAYS;

如果启用了比较功能,那么texels将首先与一个值进行比较,而比较的结果将用于过滤操作。这主要用于阴影贴图上的percentage-closer filtering。我们将在以后的章节中讨论这个问题。

samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.mipLodBias = 0.0f;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = 0.0f;

所有这些领域都适用于mipmapping。我们将在后一章中研究mipmapping,但基本上它是另一种可以应用的过滤器。

采样器的功能现在已经完全定义了。添加一个类成员来保存采样器对象的句柄,用vkCreateSampler创建采样器:

VkImageView textureImageView;
VkSampler textureSampler;

...

void createTextureSampler() {
    ...

    if (vkCreateSampler(device, &samplerInfo, nullptr, &textureSampler) != VK_SUCCESS) {
        throw std::runtime_error("failed to create texture sampler!");
    }
}

请注意,采样器并没有在任何地方引用VkImage。采样器是一个独特的对象,它提供了一个从纹理中提取颜色的接口。它可以应用于任何你想要的图像,无论它是一维、二维还是三维。这与许多旧的API不同,旧的API将纹理图像和过滤合并为一个单一的状态。

当我们不再访问图像时,在程序的最后销毁采样器:

void cleanup() {
    cleanupSwapChain();

    vkDestroySampler(device, textureSampler, nullptr);
    vkDestroyImageView(device, textureImageView, nullptr);

    ...
}

Anisotropy 设备特征

如果你现在运行你的程序,你会看到一个像这样的验证层信息:

img

这是因为anisotropic filtering实际上是一个可选的设备功能。我们需要更新createLogicalDevice函数来请求它:

VkPhysicalDeviceFeatures deviceFeatures{};
deviceFeatures.samplerAnisotropy = VK_TRUE;

而即使现代显卡不支持它的可能性很小,我们也应该更新isDeviceSuitable来检查它是否可用:

bool isDeviceSuitable(VkPhysicalDevice device) {
    ...

    VkPhysicalDeviceFeatures supportedFeatures;
    vkGetPhysicalDeviceFeatures(device, &supportedFeatures);

    return indices.isComplete() && extensionsSupported && swapChainAdequate && supportedFeatures.samplerAnisotropy;
}

vkGetPhysicalDeviceFeatures重新利用了VkPhysicalDeviceFeatures结构,通过设置布尔值来指示支持哪些功能,而不是要求哪些功能。

与其强制要求各向异性过滤的可用性,还可以通过有条件的设置简单地不使用它:

samplerInfo.anisotropyEnable = VK_FALSE;
samplerInfo.maxAnisotropy = 1.0f;

在下一章中,我们将把图像和采样器对象暴露给着色器,以便将纹理绘制到广场上。

C++ code / Vertex shader / Fragment shader

组合图像采样器

我们在本教程的统一缓冲区部分第一次看了描述符。在本章中,我们将研究一种新的描述符类型。组合图像采样器。这种描述符使得着色器可以通过像我们在上一章中创建的采样器对象来访问图像资源。

我们将首先修改描述符布局、描述符池和描述符集,以包括这样一个组合图像取样器描述符。之后,我们将为Vertex添加纹理坐标,并修改片段着色器以从纹理中读取颜色,而不是仅仅插值顶点的颜色。

更新描述符

浏览createDescriptorSetLayout函数,为组合图像采样器添加一个VkDescriptorSetLayoutBinding。我们将简单地把它放在统一缓冲区之后的绑定中:

VkDescriptorSetLayoutBinding samplerLayoutBinding{};
samplerLayoutBinding.binding = 1;
samplerLayoutBinding.descriptorCount = 1;
samplerLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
samplerLayoutBinding.pImmutableSamplers = nullptr;
samplerLayoutBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;

std::array<VkDescriptorSetLayoutBinding, 2> bindings = {uboLayoutBinding, samplerLayoutBinding};
VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = static_cast<uint32_t>(bindings.size());
layoutInfo.pBindings = bindings.data();

确保设置`stageFlags’以表明我们打算在片段着色器中使用组合图像采样器描述符。这就是片段的颜色要被确定的地方。在顶点着色器中使用纹理采样是可能的,例如通过heightmap动态地使顶点网格变形。

我们还必须创建一个更大的描述符池,为组合图像采样器的分配腾出空间,在VkDescriptorPoolCreateInfo中添加另一个类型为VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLERVkPoolSize 。转到createDescriptorPool函数,并修改它,为这个描述符包括一个VkDescriptorPoolSize:

std::array<VkDescriptorPoolSize, 2> poolSizes{};
poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
poolSizes[0].descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);
poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
poolSizes[1].descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);

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 = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);

不足的描述符池是验证层不会抓住的问题的一个很好的例子。从Vulkan 1.1开始,如果描述符池不够大,vkAllocateDescriptorSets可能会以错误代码VK_ERROR_POOL_OUT_OF_MEMORY失败,但驱动程序也可能尝试在内部解决这个问题。这意味着有时(取决于硬件、池的大小和分配的大小),驱动程序会让我们的分配超过描述符池的限制。其他时候,vkAllocateDescriptorSets会失败并返回VK_ERROR_POOL_OUT_OF_MEMORY。如果分配在某些机器上成功,但在其他机器上失败,这可能会让人特别沮丧。

由于Vulkan将分配的责任转移给了驱动程序,因此不再严格要求只分配某种类型的描述符(VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER'等),这些描述符是由相应的descriptorCount’成员为创建描述符池指定的。然而,这样做仍然是最好的做法,将来,如果你启用Best Practice ValidationVK_LAYER_KHRONOS_validation会对这种类型的问题提出警告。

最后一步是将实际的图像和采样器资源与描述符集中的描述符绑定。转到createDescriptorSets函数。

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
    VkDescriptorBufferInfo bufferInfo{};
    bufferInfo.buffer = uniformBuffers[i];
    bufferInfo.offset = 0;
    bufferInfo.range = sizeof(UniformBufferObject);

    VkDescriptorImageInfo imageInfo{};
    imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    imageInfo.imageView = textureImageView;
    imageInfo.sampler = textureSampler;

    ...
}

组合图像采样器结构的资源必须在VkDescriptorImageInfo结构中指定,就像统一缓冲区描述符的缓冲区资源在VkDescriptorBufferInfo结构中指定。这就是上一章中的对象的结合点.

std::array<VkWriteDescriptorSet, 2> descriptorWrites{};

descriptorWrites[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[0].dstSet = descriptorSets[i];
descriptorWrites[0].dstBinding = 0;
descriptorWrites[0].dstArrayElement = 0;
descriptorWrites[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
descriptorWrites[0].descriptorCount = 1;
descriptorWrites[0].pBufferInfo = &bufferInfo;

descriptorWrites[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[1].dstSet = descriptorSets[i];
descriptorWrites[1].dstBinding = 1;
descriptorWrites[1].dstArrayElement = 0;
descriptorWrites[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
descriptorWrites[1].descriptorCount = 1;
descriptorWrites[1].pImageInfo = &imageInfo;

vkUpdateDescriptorSets(device, static_cast<uint32_t>(descriptorWrites.size()), descriptorWrites.data(), 0, nullptr);

描述符必须用这个图像信息来更新,就像缓冲区一样。这一次我们使用pImageInfo数组而不是pBufferInfo。现在,描述符已经准备好被着色器使用了!

纹理坐标系

纹理映射还缺少一个重要成分,那就是每个顶点的实际坐标。坐标决定了图像是如何实际映射到几何体上的。

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

    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDescription{};
        bindingDescription.binding = 0;
        bindingDescription.stride = sizeof(Vertex);
        bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

        return bindingDescription;
    }

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

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

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

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

        return attributeDescriptions;
    }
};

修改 “Vertex”结构,以包括一个 “vec2”来表示纹理坐标。确保也添加一个VkVertexInputAttributeDescription,这样我们就可以在顶点着色器中使用访问纹理坐标作为输入。这是必要的,因为我们可以将它们传递给片段着色器,以便在正方形的表面进行插值。

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

在本教程中,我将通过使用左上角的 0, 0到右下角的 1, 1的坐标来简单地用纹理填充正方形。请随意尝试不同的坐标。试着使用低于0或高于1的坐标,看看寻址模式是如何运作的!

着色器

最后一步是修改着色器以从纹理中提取颜色。我们首先需要修改顶点着色器,将纹理坐标传递给片段着色器:

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

layout(location = 0) out vec3 fragColor;
layout(location = 1) out vec2 fragTexCoord;

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
    fragColor = inColor;
    fragTexCoord = inTexCoord;
}

就像每个顶点的颜色一样,“fragTexCoord”的值将被光栅化器平滑地插值到正方形的区域中。我们可以通过让fragment shader将纹理坐标输出为颜色来直观地看到这一点:

#version 450

layout(location = 0) in vec3 fragColor;
layout(location = 1) in vec2 fragTexCoord;

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(fragTexCoord, 0.0, 1.0);
}

你应该看到如下图所示的东西。不要忘记重新编译着色器!

img

绿色通道代表水平坐标,红色通道代表垂直坐标。黑角和黄角确认纹理坐标在整个广场上从 0,01,1正确插值。使用颜色来可视化数据是着色器编程的等同于printf调试,因为没有更好的选择!

一个组合的图像采样器描述符在GLSL中由一个采样器统一表示。在片段着色器中添加一个对它的引用:

layout(binding = 1) uniform sampler2D texSampler;

对于其他类型的图像,有同等的sampler1Dsampler3D类型。请确保在这里使用正确的绑定。

void main() {
    outColor = texture(texSampler, fragTexCoord);
}

纹理是使用内置的texture函数进行采样的。它需要一个 “采样器”和坐标作为参数。采样器会在后台自动处理过滤和转换的问题。现在当你运行应用程序时,你应该看到广场上的纹理了。

img

试着用寻址模式进行实验,将纹理坐标缩放到高于1的数值。例如,当使用VK_SAMPLER_ADDRESS_MODE_REPEAT时,下面的片段着色器产生的结果如图所示:

void main() {
    outColor = texture(texSampler, fragTexCoord * 2.0);
}
img

你也可以用顶点颜色来操作纹理颜色:

void main() {
    outColor = vec4(fragColor * texture(texSampler, fragTexCoord).rgb, 1.0);
}

我在这里把RGB和alpha通道分开,以便不对alpha通道进行缩放。

img

你现在知道如何在着色器中访问图像了!这是个非常强大的技术。当与同样被写入帧缓冲器的图像相结合时,这是一个非常强大的技术。你可以使用这些图像作为输入来实现很酷的效果,比如在3D世界中进行后期处理和相机显示。

C++ code / Vertex shader / Fragment shader

留言板