纹理映射
图像
介绍
现在几何体,已经被逐个顶点地设置了颜色,但这是一个相当受限的方案.在本章教程中我们会去实现纹理的映射从而让几何体看起来更加的有趣.这将帮助我们在未来的章节中去导入基础的3D模型。
要为程序添加纹理涉及到以下几个步骤:
- 基于设备内存创建一张图像
- 使用文件中读取出的像素点来填充它
- 创建一个图像采样器
- 添加一个包含了图像采样器的描述符可以从纹理中进行采样
我们已经在前面与图像对象打过交道了,不过之前的图像是我们通过交换链拓展自动获取到的。这一次我们会自己创建图像,创建一幅图像并用数据填充它这与顶点缓冲区的创建时类似的,我们首先创建出一个临时资源,用像素数据来填充它,然后我们拷贝这个临时对象到最终的图像对象中,最终的图像对象就是我们会在渲染中使用的。尽管也可以创建一个临时的图像,Vulkan也允许拷贝像素点到临时的图像中,并且这个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 : 适合用作着色器中的采样
一个转换图像布局最常用的方式是管线屏障(pipeline barrier),管线屏障主要用于同步资源的访问,例如确保一个图像在其读取之前已经被写入了,不过这里也可以被用于布局转换.本章会演示如何使用管线屏障完成转换.管线屏障也可以对那些使用了 VK_SHARING_MODE_EXCLUSIVE 模式的队列簇进行传输操作.
图片解析库
有许多三方库可以用于图片的加载,你甚至可以自己实现一个解析bmp或者PPM格式的图片解析器.本教程中我们使用 stb_image库,这个库的优点是所有代码都在一个单文件中,不需要复杂的配置,直接下载并存储到一个方便的位置即可,然后将路径加入到include搜索目录中.
载入图片
像下面这样包含这个图片库:
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
头文件仅定义了函数的原型,代码文件需要包含宏 STB_IMAGE_IMPLEMENTATION 与头文件,其中包含了完整的函数体代码,否则会在链接时报错。
void initVulkan() {
...
createCommandPool();
createTextureImage();
createVertexBuffer();
...
}
...
void createTextureImage() {
}
创建新函数 createTextureImage,我们会在这个函数中完成图片的载入并将图片上传给Vulkan图像对象,我们将要使用命令缓冲,所以它的调用实在createCommandPool之后.
在shader目录下面再创建一个 texture目录,用来存储纹理文件,我们准备载入一张名为 texture.jpg 的纹理文件,此图片的大小是 512 x 512 ,不过这里你可以换成任何你想要的图片,stb_image库支持主流的图片格式如jpg,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强制让图片在载入时拥有一个透明度的通道,哪怕图片本身没有透明度通道,这可以让未来我们对其它纹理的处理保持统一,中间的三个参数是输出的宽,高以及实际的通道数量,返回的指针代表像素数组的第一个元素的地址,像素数组按照每个像素点4个字节方式一行一行完成布局,总的数量为 texWidth * texHeight * 4
暂存缓冲区
现在可以在CPU可见的内存中创建一个缓冲区,以便于我们使用 vkMapMemory 将像素数据拷贝进去,在createTextureImage函数中添加临时缓冲区变量.
VkBuffer stagingBuffer;
VkDeviceMemory stagingBufferMemory;
缓冲区需要是主机内存可见的以便于我们可以映射它,并且它还需要作为一个传输操作的传输源,以便于我们后面可以将它传输到图像(image)中:
createBuffer(imageSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT );
现在可以直接将像素数据拷贝到缓冲区了
void *data;
vkMapMemory(device, stagingBufferMemory, 0, imageSize, 0, &data);
memcpy(data, pixels, static_cast<size_t>(imageSize));
vkUnmapMemory(device, stagingBufferMemory);
完成后不要忘记清理调原始的像素数据
stbi_image_free(pixels);
纹理图像
尽管我们可以让着色器去访问在缓冲区里的像素值,但是更好的方案是使用图像对象(image object).图像对象可以更快更高效地从2D坐标系中查询颜色数据,一个图像中的像素数据被称为纹素(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使用哪种坐标系统访问纹素,有可能是1D,2D或者3D的,一维的图像数据可用于存储数组对象或者梯度数据,二维图像主要被用于纹理,三维的图像可用于存储体素,extent字段指定了图像的范围大小,每个轴上有多少个纹素,所以这里的深度(depth)是1而不是0,我们的纹理并不是一个数组,所以这里并不需要使用多级纹理.
imageInfo.format = VK_FORMAT_R8G8B8A8_SRGB;
Vulkan支持许多图像格式,但我们应该使用与像素一样的格式,否则拷贝的操作会失败。
imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
tiling 字段有以下两个取值:
- VK_IMAGE_TILING_LINEAR : 纹素像像素数组一样以行为主序进行布局
- VK_IMAGE_TILING_OPTIMAL : 纹素布局以厂商内部实现为准,通常对GPU友好
与图像的布局不同,tile设定的值之后不可改变.如果你想直接访问图像内存中的数据,必须指定为 VK_IMAGE_TILING_LINEAR 模式,我们将使用临时缓冲区来代替临时图像,所以不需要设置为 LINEAR 模式,使用VK_IMAGE_TILING_OPTIMAL 通常对着色器的访问更加高效。
imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
initialLayout 字段有两个取值:
- VK_IMAGE_LAYOUT_UNDEFINED : GPU无法使用,第一次转换时会舍弃舍弃纹素.
- VK_IMAGE_LAYOUT_PREINITIALIZED : GPU无法使用,但是第一次转换时会保留纹素.
有一些场景是有必要在第一次转化的时候保存纹素数据的,例如将图像作为临时图像由CPU写入数据的场景,这里我们使用 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;
字段samples 关系到多重采样.这仅仅对那些关联为附件的图像对象有效,所以这里设置为1即可.有一些可选的flags关系到稀疏图像,稀疏图像是指只有一些特定区域有实际内存数据的图像,如果你使用3D纹理作为地形数据,你就可以避免为大量的空的天空数据分配内存.我们这里并不打算使用稀疏图像,所以这里设置flags为0.
if (vkCreateImage(device, &imageInfo, nullptr, &textureImage) != VK_SUCCESS) {
throw std::runtime_error("failed to create image!");
}
图像使用 vkCreateImage 来创建,有可能图像硬件并不支持 VK_FORMAT_R8G8B8A8_SRGB 这种格式,你应该维护一个可用的列表,在其中选择出最合适的一种格式.然而 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 中,就像我们之前创建缓冲区一样.创建函数 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;
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, ©Region);
endSingleTimeCommands(commandBuffer);
}
如果我们仍然使用缓冲区,现在就可以去写一个函数去记录并运行 vkCmdCopyBufferToImage ,但是这个 vkCmdCopyBufferToImage命令运行的前提是需要图像拥有正确的布局,创建一个新函数去处理布局的转换:
void transitionImageLayout(VkImage image, VkFormat format,
VkImageLayout oldLayout, VkImageLayout newLayout){
VkCommandBuffer commandBuffer = beginSingleTimeCommands();
//todo transition
endSingleTimeCommands(commandBuffer);
}
实现布局转换最常用的方式是使用图像内存屏障(image memory barrier),管线屏障通常用于同步对对资源的访问,例如确保在对缓冲区的读取发生在它已经被写入之后,不过当转换的队列簇的熟悉是 VK_SHARING_MODE_EXCLUSIVE 时,也可以被用作图像布局的转换,对于缓冲区有一个等效的缓冲内存屏障去实现这些.
VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.oldLayout = oldLayout;
barrier.newLayout = newLayout;
首先出现的两个字段指出了需要转换的布局,如果你并不在乎之前图像已经存在的内容,oldLayout可以使用 VK_IMAGE_LAYOUT_UNDEFINED.
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORE;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORE;
如果你正在使用屏障来转换队列簇的所有权,这两个字段就需要填写队列簇的索引值,如果并不像这么做,就必须被设置为 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;
image 和 subresourceRange 指定了需要操作的图像,我们的图像并不是一个数组,也没有多级纹理,所以给出以上设置。
barrier.srcAccessMask = 0; //TODO
barrier.dstAccessMask = 0;//TODO
屏障主要被用来做资源的同步,所以你必须指明哪一种操作需要在屏障之前发生,什么样的操作必须必须让屏障去等待,无论你是否使用了 vkQueueWaitIdle 进行了手动的同步,正确的值依赖于老的值和新的布局,所以现在让我们回到这个问题中,我们需要什么转化?
vkCmdPipelineBarrier(
commandBuffer,
0 /* TODO */, 0 /* TODO */,
0,
0, nullptr,
0, nullptr,
1, &barrier
);
所有类型的管线屏障都可以通过这个函数来提交,commandBuffer 后面的第一个参数指明在屏障之前,需要发生哪一个管线阶段,第二个参数指明屏障需要等待一个什么样的操作,指定的管线阶段依赖于你使用的资源设定的屏障,被允许设置的值参考如下列表,举个例子,如果你准备在屏障之后去读取一个 uniform 变量,则需要指定 VK_ACCESS_UNIFORM_READ_BIT 标志位,最早的着色器,如果指定的管线阶段与使用类型不相符,验证层将会产生警告信息.
第三个参数既可以是 0 也可以是 VK_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 、imageOffset 、imageExtent 表明了你想拷贝到图像的哪一部分。
缓存到图像的操作通过函数 vkCmdCopyBufferToImage 送入队列:
vkCmdCopyBufferToImage(
commandBuffer,
buffer,
image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1,
®ion
);
第四个参数指示了当前使用的图像布局,我们这里假定,图像已经被转化为了最优的像素布局,现在要做的仅仅是拷贝一块像素数据给整个图像,但是也有可能指定一个数组,从而实现一次操作多次拷贝的效果。
准备纹理图像
我们现在已经拥有了初始化纹理图像的所有工具,回到 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 ,所以在纹理图像转换时,需要指定初始布局也是这个,请记住,我们可以这么做的原因是,我们在拷贝操作前并不关心图像里面的内容是什么.
为了图像纹理可以在着色器中被采样,我们还需要最终再做一次转换:
transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_SRGB,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
转化时屏障的掩码
如果带着验证层运行你的程序,会报出 transitionImageLayout 中访问掩码与管线阶段不兼容的错误,我们仍然需要在转换中基于这些布局进行设置.
我们需要处理两个转换:
- VK_IMAGE_LAYOUT_UNDEFINED -> VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL : 转化的写入并不需要等待任何东西
- VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL -> VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL : 着色器需要等待传输写入完成,特别是在片段着色器中的读取,因为在这里我们将要使用纹理.
这些规则我们使用如下的访问掩码和管线阶段来完成:
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 函数仅运行了一条命令,你需要使用隐式的同步并且设置 srcAccessMask 为0,作者个人并不喜欢这种OpenGL风格的隐藏操作。
的确有一种特殊的图像布局支持所有的操作,成为 VK_IMAGE_LAYOUT_GENERAL ,当然它并不能提供最佳的效率,它适用于一些特殊的场景,例如图像即是输入又是输出,或者预初始化图像布局.
目前所有的提交给命令缓冲区的帮助函数都通过等待队列空闲来完成同步,对于实际应用,建议将这些操作合并在一起提交给一个命令缓冲,通过异步方式提高吞吐量,特别是其中的转换与拷贝操作,试着去创建一个 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);
...
}
图像现在已经包含了纹理,但是我们仍然需要一种在图像管线中访问它的方法,下一章会完成这个工作.
图像视图和采样器
在本章我们会创建两个在图形管线采样所需要的资源,第一个是我们之前在交换链中已经见到过的,第二个资源是一个全新的概念它关系到如何从图像中读出纹素。
纹理图像视图
之前已经看到交换链的图像和帧缓冲,图像是通过图像视图而不是直接访问的,我们也需要为这里的纹理图像创建一个图像视图。
创建一个类成员用以存储纹理的图像视图:
VkImageView textureImageView;
...
void initVulkan() {
...
createTextureImage();
createTextureImageView();
createVertexBuffer();
...
}
...
void createTextureImageView() {
}
createTextureImageView 的代码参考之前的 createImageViews,只要修改原来的 format 和 image
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.components 字段,因为 VK_COMPONENT_SWIZZLE_IDENTITY 必须被设置为 0,通过 vkCreateImageView 结束创建:
if (vkCreateImageView(device, &viewInfo, nullptr, &textureImageView) != VK_SUCCESS) {
throw std::runtime_error("failed to create texture image view!");
}
由于产生了太多的重复代码,我们可以提取创建图像的逻辑到一个单独的函数中命名为 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);
...
}
采样器
着色器的确有可能直接从图像中读取纹素,但这个情况并不常见,纹理通常是通过采样器进行访问的,它会进行滤波与转换以得到最终的颜色.
对于过采样场景,滤波器是很有用的,考虑一张纹理被映射到比自身尺寸要更大的片段上,如果你仅仅取距离纹理坐标最近的像素点,只会得到第一张画面:
如果通过线性插值结合最近的四个像素点,将会得到一个更加平滑的结果,如右图所示。当然你的应用可能有特殊的艺术风格(例如 我的世界),但实际右图在图形程序中才更加常见,采样器会在取纹理像素时,自动应用此过滤效果。
欠采样则是一种相反的场景,你有比片段更多的纹理,当对高频图案进行采样时,会产生伪影。
如左图所示,远处的纹理变得模糊不清,解决的方法是使用各向异性过滤,采样器中也会自动使用它.
除了这些过滤器,采样器也可以进行一些变换操作,当你读取的纹理坐标超出范围时,它会决定采用何种行为模式,下图展示了其中的一些效果:
我们现在创建 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;
magFilter 和 minFilter 指定了当图像是放大或缩小时怎样进行插值.放大场景对应过采样设置,缩小对应欠采样,选择可以是 VK_FILTER_NEAREST 或者 VK_FILTER_LINEAR.
samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT;
samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT;
addressMode 可以按轴指定值,可用的值在下面列出,注意纹理坐标的轴被称为 X、V、W 而不是 X、Y、Z.
- VK_SAMPLER_ADDRESS_MODE_REPEAT : 当超过图像范围时,重复纹理.
- VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT : 与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 = ???;
这两个字段指定了各向异性过滤是否开启,并没有不开启的理由,maxAnisotrogy限制了纹素采样的数量,值越低效率越高,但换来的是更低的质量,为了查找出合适的值我们需要接收物理设备特性的查询结果:
VkPhysicalDeviceProperties properties{};
vkGetPhysicalDeviceProperties(physicalDevice, &properties);
如果查看一下 VkPhysicalDeviceProperties 结构体的文档,会找到 VkPhysicalDeviceLimits 成员变量,这个结构体里有一个名为 maxSamplerAnisotropy 的变量,我们可以将这个值设置为 maxAnisotrogy.
samplerInfo.maxAnisotropy = properties.limits.maxSamplerAnisotropy;
既可以在程序开始的时查询这个值,也可以在创建纹理时再进行查询.
samplerInfo.borderColor = VK_BORDER_COLOR_INT_OPAQUE_BLACK;
borderColor 指定了采样坐标超出边界时使用边界模式所采用的颜色,它可能会返回黑色,白色或者透明的颜色,并不能指定任意颜色。
samplerInfo.unnormalizedCoordinates = VK_FALSE;
unnormalizedCoordinates 指定了使用哪一种纹理坐标,如果设置为VK_TRUE,就可以简单的使用 图片的宽高像素为坐标,如果设置为VK_FALSE,纹素在所有轴上都被定位为 0.0 ~ 1.0 范围,实际应用程序都会使用归一化的坐标,这样可以更好的适配不同分辨率的场景.
samplerInfo.compareEnable = VK_FALSE;
samplerInfo.compareOp = VK_COMPARE_OP_ALWAYS;
如果比较函数设置为可用了,纹素会与一个值进行比较,比较的结果会被用于过滤操作, 这主要用于阴影的生成.
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.mipLodBias = 0.0f;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = 0.0f;
上面这些都是都是关于多级纹理映射的,可以简单理解为它是另外一种过滤模式.
采样器的功能现在已经定义完成了,现在添加一个类成员去存下新建出的采样器句柄。
VkImageView textureImageView;
VkSampler textureSampler;
...
void createTextureSampler() {
...
if (vkCreateSampler(device, &samplerInfo, nullptr, &textureSampler) != VK_SUCCESS) {
throw std::runtime_error("failed to create texture sampler!");
}
}
采样器并没有引用过 VkImage 对象,它仅仅是提供了一个从纹理中解析颜色的接口,可以被应用于任何你想要的图像,无论图像是1D,2D 还是3D的.这一点是不同于之前的API的,以前的API会把纹理图像和采样对象绑定到一个状态中.
在程序结束,我们不再需要访问图像时,销毁采样器
void cleanup() {
cleanupSwapChain();
vkDestroySampler(device, textureSampler, nullptr);
vkDestroyImageView(device, textureImageView, nullptr);
...
}
各向异性设备特性
如果现在运行程序会看到验证层报了如下的错误.
这是因为各向异性过滤是一个设备可选的特性,我们需要更新 createLogicDevice 来请求这个特性的使用.
VkPhysicalDeviceFeatures deviceFeatures{};
deviceFeatures.samplerAnisotropy = VK_TRUE;
尽管现代显卡不太可能不支持这个特性,我们仍然需要在 isDeviceSuitable 中检查此特性是否可用。
bool isDeviceSuitable(VkPhysicalDevice device) {
...
VkPhysicalDeviceFeatures supportedFeatures;
vkGetPhysicalDeviceFeatures(device, &supportedFeatures);
return indices.isComplete()
&& extensionsSupported
&& swapChainAdequate
&& supportedFeatures.samplerAnisotropy;
}
当然我们也可以不使用此特性:
samplerInfo.anisotropyEnable = VK_FALSE;
samplerInfo.maxAnisotropy = 1.0f;
下一节,我们会将图像和采样器对象暴露给着色器对象,着色器将纹理贴到集合体上。
图像采样器
介绍
我们首次接触描述符是在统一变量缓冲区中,在这一节,会看到一个新的描述符:图像采样器,这个描述符可以让着色器去访问我们之前创建的图像和采样器对象.
我们将会开始修改描述符布局,描述符池以及描述符集合,从而让它们包含一个图像采样器描述符,在此之后,会添加纹理坐标给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 表示我们要在片段着色器中使用图像采样器,也有可能会在顶点着色器中使用纹理,例如网格化后存储地图的高度.
现在必须创建一个更大的描述符池,增加一个 VkPoolSize 对象,类型为 VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,在 createDescriptorPool 函数中修改它:
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 将原来驱动管理的分配任务交给了应用层,所以不用再严格要求在创建描述符池时类型和数量要严格相等,尽管,这是最佳实践,在未来,验证层可能会对此类错误给出警告。
最后一步,时绑定实际的图像和采样器资源给描述符,在 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);
描述符必须通过 imageInfo 来更新,这次我们使用 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)点对应右下角,可自行修改数值观看映射的纹理效果。
着色器
最后一步是修改着色器,从纹理中采样,我们首先要修改顶点着色器让它传递纹理坐标给片段着色器。
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 的值也会被光栅器完成平滑插值,我们可以让纹理坐标可视化来查看传递给纹理坐标的值.
#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);
}
不要忘记重新编译着色器,然后你将看到如下图片:
绿色通道代表水平坐标值,红色通道代表垂直坐标值,边角上是黑色和黄色代表坐标值是正确的,使用颜色来将数据可视化是在没有更好的调试选择下,一个等价于printf的调试方案.
图像采样器在着色器中使用一个采样统一变量来代表(sampler uniform),在片段着色器中增加它的引用:
layout(binding = 1) uniform sampler2D texSampler;
也有等价的 sampler1D 和 sampler3D 类型,确保这里使用了正确的绑定索引值(binding).
void main() {
outColor = texture(texSampler, fragTexCoord);
}
纹理使用内建的 texture 函数完成采样,它接收一个采样器和一个纹理坐标作为参数,采样器会在后台自动处理滤波和转换,现在就可以在正方形上看到纹理了.
试着修改一下纹理坐标,让值大于1,如果设置的是 VK_SAMPLER_ADDRESS_MODE_REPEAT ,会看到如下图像
void main() {
outColor = texture(texSampler, fragTexCoord * 2.0);
}
也可以手动修改纹理的颜色
void main() {
outColor = vec4(fragColor * texture(texSampler, fragTexCoord).rgb, 1.0);
}
在这里我们将RGB 与 透明度通道分开
现在已经知道如何在着色器中访问图像了,结合图像写入帧缓冲是一个非常强大的技术,可以使用这些图像作为输入来实现很酷的效果,例如后处理,或者是3D世界里相机视角的展示等。