图像
简介
到目前为止,几何体都是通过逐顶点着色来上色的,这种方式相当有限。在本教程的这一部分,我们将实现纹理映射,使几何体看起来更有趣。这也将允许我们在未来的章节中加载和绘制基本的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`时用于传输队列族所有权。
加载图像
像这样包含图像库:
#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`的图像。我选择了以下https://pixabay.com/en/statue-sculpture-fig-historically-1275469/[CC0许可的图像],调整为512x512像素,但您可以随意选择任何图像。该库支持大多数常见的图像文件格式,如JPEG、PNG、BMP和GIF。
使用这个库加载图像非常简单:
void createTextureImage() {
int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load("textures/texture.jpg", &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
vk::DeviceSize 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`函数中为此临时缓冲区添加变量:
vk::raii::Buffer stagingBuffer({});
vk::raii::DeviceMemory stagingBufferMemory({});
缓冲区应该在主机可见内存中,以便我们可以映射它,并且应该可用作传输源,以便稍后可以将其复制到图像中:
createBuffer(imageSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, stagingBuffer, stagingBufferMemory);
然后我们可以直接将从图像加载库获得的像素值复制到缓冲区:
void* data = stagingBufferMemory.mapMemory(0, imageSize);
memcpy(data, pixels, imageSize);
stagingBufferMemory.unmapMemory();
现在记得清理原始像素数组:
stbi_image_free(pixels);
纹理图像
虽然我们可以设置着色器来访问缓冲区中的像素值,但在Vulkan中最好为此目的使用图像对象。图像对象通过允许我们使用2D坐标来检索颜色,使操作更简单和更快。图像对象中的像素被称为纹素,从现在开始我们将使用这个名称。添加以下新的类成员:
vk::raii::Image textureImage = nullptr;
vk::raii::DeviceMemory textureImageMemory = nullptr;
图像的参数在`VkImageCreateInfo`结构中指定:
vk::ImageCreateInfo imageInfo( {}, vk::ImageType::e2D, format, {width, height, 1}, 1, 1, vk::SampleCountFlagBits::e1, tiling, usage, vk::SharingMode::eExclusive, 0);
imageType`字段中指定的图像类型告诉Vulkan图像中的纹素将使用哪种坐标系进行寻址。可以创建1D、2D和3D图像。一维图像可用于存储数据数组或渐变,二维图像主要用于纹理,三维图像可用于存储体素体积等。`extent`字段指定图像的尺寸,基本上是每个轴上的纹素数量。这就是为什么`depth`必须是`1`而不是`0。我们的纹理不会是一个数组,并且我们现在不会使用mipmapping。
Vulkan支持许多可能的图像格式,但我们应该使用与缓冲区中的像素相同的格式作为纹素,否则复制操作将失败。
`tiling`字段可以有两个值之一:
-
VK_IMAGE_TILING_LINEAR:纹素按行主序排列,就像我们的`pixels`数组 -
VK_IMAGE_TILING_OPTIMAL:纹素按实现定义的顺序排列以获得最佳访问
与图像的布局不同,平铺模式不能在以后更改。如果您希望能够直接访问图像内存中的纹素,则必须使用`VK_IMAGE_TILING_LINEAR`。我们将使用暂存缓冲区而不是暂存图像,因此这不是必需的。我们将使用`VK_IMAGE_TILING_OPTIMAL`以便从着色器高效访问。
图像的`initialLayout`只有两个可能的值:
-
VK_IMAGE_LAYOUT_UNDEFINED:GPU不可用,第一次转换将丢弃纹素。 -
VK_IMAGE_LAYOUT_PREINITIALIZED:GPU不可用,但第一次转换将保留纹素。
在第一次转换期间需要保留纹素的情况很少。然而,一个例子是如果您想将图像与`VK_IMAGE_TILING_LINEAR`布局一起用作暂存图像。在这种情况下,您希望将纹素数据上传到它,然后将图像转换为传输源而不丢失数据。然而,在我们的例子中,我们首先将图像转换为传输目标,然后从缓冲区对象复制纹素数据到它,因此我们不需要此属性,可以安全地使用`VK_IMAGE_LAYOUT_UNDEFINED`。
usage`字段与缓冲区创建时的语义相同。图像将用作缓冲区复制的目标,因此应设置为传输目标。我们还希望能够从着色器访问图像以着色我们的网格,因此使用应包括`VK_IMAGE_USAGE_SAMPLED_BIT。
图像将仅由一个队列族使用:支持图形(因此也包括传输)操作的队列族。
samples`标志与多重采样相关。这仅适用于将用作附件的图像,因此请坚持使用一个样本。图像还有一些可选标志与稀疏图像相关。稀疏图像是只有某些区域实际由内存支持的图像。例如,如果您使用3D纹理作为体素地形,则可以使用此功能避免分配内存来存储大量的“空气”值。我们不会在本教程中使用它,因此请将其保留为默认值`0。
image = vk::raii::Image( device, imageInfo );
使用`vkCreateImage`创建图像,它没有任何特别值得注意的参数。`VK_FORMAT_R8G8B8A8_SRGB`格式可能不被图形硬件支持。您应该有一个可接受的替代列表,并选择支持的最佳格式。然而,对这种特定格式的支持非常广泛,因此我们将跳过此步骤。使用不同的格式还需要烦人的转换。我们将在深度缓冲区章节中回到这一点,在那里我们将实现这样的系统。
vk::MemoryRequirements memRequirements = image.getMemoryRequirements();
vk::MemoryAllocateInfo allocInfo( memRequirements.size, findMemoryType(memRequirements.memoryTypeBits, properties) );
imageMemory = vk::raii::DeviceMemory( device, allocInfo );
image.bindMemory(*imageMemory, 0);
为图像分配内存的工作方式与为缓冲区分配内存完全相同。使用`vkGetImageMemoryRequirements`而不是`vkGetBufferMemoryRequirements`,并使用`vkBindImageMemory`而不是`vkBindBufferMemory`。
这个函数已经变得相当大,并且在未来的章节中需要创建更多的图像,因此我们应该将图像创建抽象到一个`createImage`函数中,就像我们对缓冲区所做的那样。创建该函数并将图像对象创建和内存分配移到其中:
void createImage(uint32_t width, uint32_t height, vk::Format format, vk::ImageTiling tiling, vk::ImageUsageFlags usage, vk::MemoryPropertyFlags properties, vk::raii::Image& image, vk::raii::DeviceMemory& imageMemory) {
vk::ImageCreateInfo imageInfo( {}, vk::ImageType::e2D, format, {width, height, 1}, 1, 1, vk::SampleCountFlagBits::e1, tiling, usage, vk::SharingMode::eExclusive, 0);
image = vk::raii::Image( device, imageInfo );
vk::MemoryRequirements memRequirements = image.getMemoryRequirements();
vk::MemoryAllocateInfo allocInfo( memRequirements.size, findMemoryType(memRequirements.memoryTypeBits, properties) );
imageMemory = vk::raii::DeviceMemory( device, allocInfo );
image.bindMemory(imageMemory, 0);
}
我将宽度、高度、格式、平铺模式、使用和内存属性作为参数,因为这些将在本教程中创建的所有图像之间变化。
`createTextureImage`函数现在可以简化为:
void createTextureImage() {
int texWidth, texHeight, texChannels;
stbi_uc* pixels = stbi_load("textures/texture.jpg", &texWidth, &texHeight, &texChannels, STBI_rgb_alpha);
vk::DeviceSize imageSize = texWidth * texHeight * 4;
if (!pixels) {
throw std::runtime_error("failed to load texture image!");
}
vk::raii::Buffer stagingBuffer({});
vk::raii::DeviceMemory stagingBufferMemory({});
createBuffer(imageSize, vk::BufferUsageFlagBits::eTransferSrc, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, stagingBuffer, stagingBufferMemory);
void* data = stagingBufferMemory.mapMemory(0, imageSize);
memcpy(data, pixels, imageSize);
stagingBufferMemory.unmapMemory();
stbi_image_free(pixels);
vk::raii::Image textureImageTemp({});
vk::raii::DeviceMemory textureImageMemoryTemp({});
createImage(texWidth, texHeight, vk::Format::eR8G8B8A8Srgb, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled, vk::MemoryPropertyFlagBits::eDeviceLocal, textureImageTemp, textureImageMemoryTemp);
}
布局转换
如前所述,Vulkan中的图像可以存在于不同的布局中,这些布局会影响像素数据在内存中的组织方式。这些布局针对特定操作进行了优化——一些布局更适合从着色器中读取,其他更适合作为渲染目标,还有一些更适合作为传输操作的源或目标。
布局转换是Vulkan设计的一个关键方面,它让您明确控制这些内存组织。与其他一些图形API不同,驱动程序会自动处理这些转换,而Vulkan要求您显式管理它们。这种方法允许更好的性能优化,因为您可以精确安排转换时间并高效地批处理操作。
对于我们的纹理图像,我们需要执行几次转换: 1. 从初始的未定义布局转换为适合接收数据的布局(传输目标) 2. 从传输目标转换为适合着色器读取的布局,以便我们的片段着色器可以从中采样
这些转换是使用管线屏障执行的,它不仅更改图像布局,还确保访问图像的操作之间正确同步。如果没有适当的同步,我们可能会遇到竞争条件,其中着色器在复制操作完成之前尝试从纹理读取。
我们现在要编写的函数再次涉及记录和执行命令缓冲区,因此现在是将其逻辑移动到辅助函数中的好时机:
vk::raii::CommandBuffer beginSingleTimeCommands() {
vk::CommandBufferAllocateInfo allocInfo(commandPool, vk::CommandBufferLevel::ePrimary, 1);
vk::raii::CommandBuffer commandBuffer = std::move(device.allocateCommandBuffers(allocInfo).front());
vk::CommandBufferBeginInfo beginInfo( vk::CommandBufferUsageFlagBits::eOneTimeSubmit );
commandBuffer.begin(beginInfo);
return commandBuffer;
}
void endSingleTimeCommands(vk::raii::CommandBuffer& commandBuffer) {
commandBuffer.end();
vk::SubmitInfo submitInfo( {}, {}, {*commandBuffer});
graphicsQueue.submit(submitInfo, nullptr);
graphicsQueue.waitIdle();
}
这些函数的代码基于`copyBuffer`中的现有代码。您现在可以简化该函数为:
void copyBuffer(vk::raii::Buffer & srcBuffer, vk::raii::Buffer & dstBuffer, vk::DeviceSize size) {
vk::raii::CommandBuffer commandCopyBuffer = beginSingleTimeCommands();
commandCopyBuffer.copyBuffer(srcBuffer, dstBuffer, vk::BufferCopy(0, 0, size));
endSingleTimeCommands(commandCopyBuffer);
}
如果我们仍然使用缓冲区,那么我们现在可以编写一个函数来记录和执行`vkCmdCopyBufferToImage`以完成工作,但此命令要求图像首先处于正确的布局中。创建一个新函数来处理布局转换:
void transitionImageLayout(const vk::raii::Image& image, vk::ImageLayout oldLayout, vk::ImageLayout newLayout) {
auto commandBuffer = beginSingleTimeCommands();
endSingleTimeCommands(commandBuffer);
}
执行布局转换的最常见方法之一是使用_图像内存屏障_。这样的管线屏障通常用于同步对资源的访问,例如确保在读取之前完成对缓冲区的写入,但它也可以用于转换图像布局和在`VK_SHARING_MODE_EXCLUSIVE`时传输队列族所有权。对于缓冲区,有一个等效的_缓冲区内存屏障_来执行此操作。
vk::ImageMemoryBarrier barrier( {}, {}, oldLayout, newLayout, {}, {}, image, { vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1 } );
前两个字段指定布局转换。如果您不关心图像的现有内容,可以使用`VK_IMAGE_LAYOUT_UNDEFINED`作为`oldLayout`。
如果您使用屏障来传输队列族所有权,那么这两个字段应该是队列族的索引。如果您不想这样做(不是默认值!),则必须将它们设置为`VK_QUEUE_FAMILY_IGNORED`。
`image`和`subresourceRange`指定受影响的图像和图像的特定部分。我们的图像不是数组,也没有mipmapping级别,因此只指定一个级别和一个层。
屏障主要用于同步目的,因此您必须指定涉及资源的哪些类型的操作必须在屏障之前发生,以及涉及资源的哪些操作必须等待屏障。尽管我们已经使用`vkQueueWaitIdle`手动同步,但我们仍然需要这样做。正确的值取决于旧布局和新布局,因此一旦我们弄清楚我们将使用哪些转换,我们将回到这一点。
commandBuffer.pipelineBarrier( sourceStage, destinationStage, {}, {}, nullptr, barrier );
所有类型的管线屏障都使用相同的函数提交。命令缓冲区之后的第一个参数指定屏障之前应该发生的操作所在的管线阶段。第二个参数指定操作将在其中等待屏障的管线阶段。在屏障之前和之后可以指定的管线阶段取决于您如何在屏障之前和之后使用资源。允许的值列在规范的https://www.khronos.org/registry/vulkan/specs/1.3-extensions/html/chap7.html#synchronization-access-types-supported[此表]中。例如,如果您在屏障之后从uniform读取,则可以指定使用`VK_ACCESS_UNIFORM_READ_BIT`和将从uniform读取的最早着色器作为管线阶段,例如`VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT`。为此类使用指定非着色器管线阶段没有意义,验证层将在您指定与使用类型不匹配的管线阶段时发出警告。
第三个参数是`0`或`VK_DEPENDENCY_BY_REGION_BIT`。后者将屏障转换为按区域条件。这意味着实现可以允许从已经写入的资源部分开始读取。
最后三对参数引用三种可用类型的管线屏障数组:内存屏障、缓冲区内存屏障和我们在此使用的图像内存屏障。请注意,我们还没有使用`VkFormat`参数,但我们将在深度缓冲区章节中为特殊转换使用它。
从缓冲区复制到图像
在我们回到`createTextureImage`之前,我们将再编写一个辅助函数:copyBufferToImage:
void copyBufferToImage(const vk::raii::Buffer& buffer, vk::raii::Image& image, uint32_t width, uint32_t height) {
vk::raii::CommandBuffer commandBuffer = beginSingleTimeCommands();
endSingleTimeCommands(commandBuffer);
}
与缓冲区复制一样,您需要指定缓冲区的哪一部分将复制到图像的哪一部分。这通过`VkBufferImageCopy`结构完成:
vk::BufferImageCopy region( 0, 0, 0, { vk::ImageAspectFlagBits::eColor, 0, 0, 1 }, {0, 0, 0}, {width, height, 1});
这些字段大多是不言自明的。bufferOffset`指定像素值开始的缓冲区中的字节偏移量。`bufferRowLength`和`bufferImageHeight`字段指定像素在内存中的布局方式。例如,您可以在图像的行之间有一些填充字节。为两者指定`0`表示像素像我们的情况一样紧密打包。`imageSubresource、`imageOffset`和`imageExtent`字段指示我们要将像素复制到图像的哪一部分。
使用`vkCmdCopyBufferToImage`函数将缓冲区到图像的复制操作排队:
commandBuffer.copyBufferToImage(buffer, image, vk::ImageLayout::eTransferDstOptimal, {region});
第四个参数指示图像当前使用的布局。我在这里假设图像已经转换为适合将像素复制到的布局。现在我们只将一个像素块复制到整个图像,但可以指定一个`VkBufferImageCopy`数组,以在一次操作中从此缓冲区执行许多不同的复制到图像的操作。
准备纹理图像
我们现在拥有完成设置纹理图像所需的所有工具,因此我们回到`createTextureImage`函数。我们最后在那里做的是创建纹理图像。下一步是将暂存缓冲区复制到纹理图像。这涉及两个步骤:
-
将纹理图像转换为`VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL`
-
执行缓冲区到图像的复制操作
使用我们刚刚创建的函数很容易做到这一点:
transitionImageLayout(textureImage, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal);
copyBufferToImage(stagingBuffer, textureImage, static_cast<uint32_t>(texWidth), static_cast<uint32_t>(texHeight));
图像是用`VK_IMAGE_LAYOUT_UNDEFINED`布局创建的,因此在转换`textureImage`时应将其指定为旧布局。请记住,我们可以这样做,因为在执行复制操作之前我们不关心其内容。
为了能够从着色器中的纹理图像开始采样,我们需要最后一次转换以准备它用于着色器访问:
transitionImageLayout(textureImage, vk::ImageLayout::eTransferDstOptimal, vk::ImageLayout::eShaderReadOnlyOptimal);
转换屏障掩码
如果您现在运行启用了验证层的应用程序,您会看到它抱怨`transitionImageLayout`中的访问掩码和管线阶段无效。我们仍然需要根据转换中的布局设置这些。
我们需要处理两个转换:
-
未定义 → 传输目标:不需要等待任何内容的传输写入
-
传输目标 → 着色器读取:着色器读取应等待传输写入,特别是片段着色器中的着色器读取,因为我们将在那里使用纹理
这些规则使用以下访问掩码和管线阶段指定:
vk::PipelineStageFlags sourceStage;
vk::PipelineStageFlags destinationStage;
if (oldLayout == vk::ImageLayout::eUndefined && newLayout == vk::ImageLayout::eTransferDstOptimal) {
barrier.srcAccessMask = {};
barrier.dstAccessMask = vk::AccessFlagBits::eTransferWrite;
sourceStage = vk::PipelineStageFlagBits::eTopOfPipe;
destinationStage = vk::PipelineStageFlagBits::eTransfer;
} else if (oldLayout == vk::ImageLayout::eTransferDstOptimal && newLayout == vk::ImageLayout::eShaderReadOnlyOptimal) {
barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
sourceStage = vk::PipelineStageFlagBits::eTransfer;
destinationStage = vk::PipelineStageFlagBits::eFragmentShader;
} else {
throw std::invalid_argument("unsupported layout transition!");
}
commandBuffer.pipelineBarrier( sourceStage, destinationStage, {}, {}, nullptr, barrier );
正如您在前述表格中看到的,传输写入必须发生在管线传输阶段。由于写入不需要等待任何内容,您可以为屏障前操作指定一个空的访问掩码和最早的管线阶段`VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT`。应该注意的是,`VK_PIPELINE_STAGE_TRANSFER_BIT`不是图形和计算管线中的_真实_阶段。它更像是传输发生的伪阶段。有关更多信息和其他伪阶段的示例,请参阅https://www.khronos.org/registry/vulkan/specs/1.3-extensions/html/chap7.html#VkPipelineStageFlagBits[文档]。
图像将在同一管线阶段写入,随后由片段着色器读取,这就是为什么我们在片段着色器管线阶段指定着色器读取访问。
如果我们需要在未来进行更多转换,我们将扩展该函数。应用程序现在应该成功运行,尽管当然还没有视觉上的变化。
需要注意的一点是,命令缓冲区提交会导致在开始时隐式`VK_ACCESS_HOST_WRITE_BIT`同步。由于`transitionImageLayout`函数执行仅包含一个命令的命令缓冲区,如果您在布局转换中需要`VK_ACCESS_HOST_WRITE_BIT`依赖项,则可以使用此隐式同步并将`srcAccessMask`设置为`0`。由您决定是否要明确说明这一点,但我个人不喜欢依赖这些类似OpenGL的“隐藏”操作。
实际上有一种支持所有操作的图像布局类型,VK_IMAGE_LAYOUT_GENERAL。当然,它的问题在于它不一定为任何操作提供最佳性能。它需要用于一些特殊情况,例如将图像同时用作输入和输出,或者在图像离开预初始化布局后读取图像。
到目前为止,所有提交命令的辅助函数都已设置为通过等待队列变为空闲来同步执行。对于实际应用程序,建议将这些操作组合在单个命令缓冲区中并异步执行以获得更高的吞吐量,特别是`createTextureImage`函数中的转换和复制。尝试通过创建一个`setupCommandBuffer`来实验这一点,辅助函数将命令记录到其中,并添加一个`flushSetupCommands`来执行到目前为止记录的命令。最好在纹理映射工作后执行此操作,以检查纹理资源是否仍然正确设置。
图像现在包含纹理,但我们仍然需要一种从图形管线访问它的方法。我们将在下一章中对此进行处理。