Ch5-3 2D贴图数组
本节将简单介绍2D贴图数组的使用方式,并完成相应封装。
阅读本节前请确保已阅读Ch5-2 2D贴图及生成Mipmap和Ch7-7 使用贴图。
2D贴图数组
如果将一系列图像格式和尺寸相同的2D图像放在同一个VkImage对象底下管理,则这个VkImage对象代表一个2D图像数组。
你可以把这些尺寸相同的贴图平铺到一张贴图中,构成贴图集(texture atlas,网络上一般叫纹理图集)。在Vulkan中你可以将一套贴图集扔进2D图像(VK_IMAGE_TYPE_2D)中,但存在不得不使用2D图像数组(VK_IMAGE_TYPE_2D_ARRAY)的情况,就我的经验而言:
1.图像尺寸太大,不得不切割的情况。
比如实现文字渲染,如果把中日韩所有汉字的字形渲染到一张贴图集上且字号够大, 那么图像的长或宽可以动辄达到上万像素,而英特尔核显限制2D图像的长或宽不超过16384。
2.为了避免溢色(color bleeding)。
如果不对贴图集进行切割,而是根据贴图子块的所在位置计算纹理坐标进行采样,放大渲染并应用线性滤波,便会发生溢色:
原因不难理解,溢色是线型滤波的结果,即,采样到贴图子块的边缘时,使用了相邻的另一贴图子块的边缘像素进行线性插值所致(因此渲染左上角的贴图子块时,溢色发生在右侧和底侧)。原图被放大得越多,溢色的效果就越显著。
如果使用贴图数组,每个贴图子块单独构成一个图层,那么只要寻址模式设定为VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER,就不必担心溢色的问题了。
封装为texture2dArray类
从texture派生出texture2dArray,新增的成员变量有记录图像大小的extent和记录图层数的layerCount:
class texture2dArray :public texture { protected: VkExtent2D extent = {}; uint32_t layerCount = 0; //-------------------- void Create_Internal(VkFormat format_initial, VkFormat format_final, bool generateMipmap) { /*待填充*/ } public: texture2dArray() = default; texture2dArray(const char* filepath, VkExtent2D extentInTiles, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { Create(filepath, extentInTiles, format_initial, format_final, generateMipmap); } texture2dArray(const uint8_t* pImageData, VkExtent2D fullExtent, VkExtent2D extentInTiles, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { Create(pImageData, fullExtent, extentInTiles, format_initial, format_final, generateMipmap); } texture2dArray(arrayRef<const char* const> filepaths, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { Create(filepaths, format_initial, format_final, generateMipmap); } texture2dArray(arrayRef<const uint8_t* const> psImageData, VkExtent2D extent, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { Create(psImageData, extent, format_initial, format_final, generateMipmap); } //Getter VkExtent2D Extent() const { return extent; } uint32_t Width() const { return extent.width; } uint32_t Height() const { return extent.height; } uint32_t LayerCount() const { return layerCount; } //Non-const Function //从硬盘读取贴图集,并切割 void Create(const char* filepath, VkExtent2D extentInTiles, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { /*待填充*/ } //从内存读取贴图集的文件数据,并切割 void Create(const uint8_t* pImageData, VkExtent2D extent, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { /*待填充*/ } //从硬盘读取多个大小相同的图像文件 Create(arrayRef<const char* const> filepaths, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { /*待填充*/ } //从内存读取多个大小相同的图像文件的数据 Create(arrayRef<const uint8_t* const> psImageData, VkExtent2D extent, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { /*待填充*/ } };
读取贴图集并切割
来填充前面的Create(...)函数,先来处理需要对贴图集中的子块进行切割的情况。
填充Create(...)的首个参数为文件地址的重载,首先判定图层总数是否超过硬件限制的最大值。extentInTiles这个参数是贴图集纵横两方向的单元数,因此切割后得到的图层总数就是extentInTiles.width * extentInTiles.height
,若该值大于硬件限制则直接返回。
void Create( const char* filepath, //贴图集图像文件的路径 VkExtent2D extentInTiles, //贴图集纵横两方向的单元数 VkFormat format_initial, //初始图像格式 VkFormat format_final, //目标图像格式 bool generateMipmap = true) { //判定图层数是否超过硬件容许的最大值 if (extentInTiles.width * extentInTiles.height > graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers) { outStream << std::format( "[ texture2dArray ] ERROR\nLayer count is out of limit! Must be less than: {}\nFile: {}\n", graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers, filepath); return; } /*待后续填充*/ }
-
通常而言,VkPhysicalDeviceLimits::maxImageArrayLayers不会小于2048(任何支持OpenGL4.5和DirectX11的硬件都满足这一要求)。
确认图层数在限制内,就可以读图了:
VkExtent2D fullExtent; formatInfo formatInfo = FormatInfo(format_initial); std::unique_ptr<uint8_t[]> pImageData = LoadFile(filepath, fullExtent, formatInfo);
读取图像后,判定图像的长宽fullExtent能否extentInTiles整除,不能整除的话就不知道该咋办了呀~,抛出错误信息。否则,调用Create(...)的首个参数为内存地址的重载:
void Create( const char* filepath, VkExtent2D extentInTiles, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { if (extentInTiles.width * extentInTiles.height > graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers) { outStream << std::format( "[ texture2dArray ] ERROR\nLayer count is out of limit! Must be less than: {}\nFile: {}\n", graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers, filepath); return; } VkExtent2D fullExtent; formatInfo formatInfo = FormatInfo(format_initial); std::unique_ptr<uint8_t[]> pImageData = LoadFile(filepath, fullExtent, formatInfo); if (pImageData) if (fullExtent.width % extentInTiles.width || fullExtent.height % extentInTiles.height) outStream << std::format( "[ texture2dArray ] ERROR\nImage not available!\nFile: {}\nImage width must be in multiples of {}\nImage height must be in multiples of {}\n", filepath, extentInTiles.width, extentInTiles.height); else Create(pImageData.get(), fullExtent, extentInTiles, format_initial, format_final, generateMipmap); }
填充Create(...)的首个参数为内存地址的重载。
考虑到该函数可能被外部直接调用,因此也在其开头判定会否满足图层限制,以及fullExtent能否被extentInTiles整除,顺便记下图层数layerCount。
void Create(const uint8_t* pImageData, VkExtent2D extent, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { layerCount = extentInTiles.width * extentInTiles.height; if (layerCount > graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers) { outStream << std::format( "[ texture2dArray ] ERROR\nLayer count is out of limit! Must be less than: {}\n", graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers); return; } if (fullExtent.width % extentInTiles.width || fullExtent.height % extentInTiles.height) outStream << std::format( "[ texture2dArray ] ERROR\nImage not available!\nImage width must be in multiples of {}\nImage height must be in multiples of {}\n", extentInTiles.width, extentInTiles.height); /*待后续填充*/ }
-
跟前一个函数的错误信息的差别在于本函数中不打印文件名(因为传入的参数就没有文件名嘛)。没错,如果通过前一个函数调用的话就重复判定了,可以对两个函数做进一步包装来规避这个问题,这里简化叙事按下不表。
计算单张图像的大小:
extent.width = fullExtent.width / extentInTiles.width; extent.height = fullExtent.height / extentInTiles.height;
计算每个像素的数据大小,以及整张图像的数据大小,以便之后将数据存入暂存缓冲区:
size_t dataSizePerPixel = FormatInfo(format_initial).sizePerPixel; size_t imageDataSize = dataSizePerPixel * fullExtent.width * fullExtent.height;
接着有两个分支,如果图像每行的单元数是1(即贴图子块是纵向地一个个排下来的),那么将图像数据直接存入暂存缓冲区:
if (extentInTiles.width == 1) stagingBuffer::BufferData_MainThread(pImageData, imageDataSize); else { /*待后续填充*/ }
否则,对图像进行“切割”。
每个贴图子块的数据最终应该是逐行紧密排列,一个贴图子块的数据全部完了以后,才轮到下一个贴图子块的数据。而else分支中的情况是,首个贴图子块的第1行的数据后,跟着的是第二个贴图子块第1行的数据,而非首个贴图子块第2行的数据。所以这一步实际上是要对数据进行搬运。
做法很简单,只要把一个贴图子块的数据逐行复制进暂存缓冲区,再把下一个贴图子块的数据逐行复制进去,如此反复即可:
if (extentInTiles.width == 1) stagingBuffer::BufferData_MainThread(pImageData, imageDataSize); else { //先映射缓冲区,取得用于访问内存区域的指针 uint8_t* pData_dst = static_cast<uint8_t*>(stagingBuffer::MapMemory_MainThread(imageDataSize)); //计算每个贴图子块每行的数据大小 size_t dataSizePerRow = dataSizePerPixel * extent.width; //双循环遍历每个贴图子块 for (size_t j = 0; j < extentInTiles.height; j++) for (size_t i = 0; i < extentInTiles.width; i++) //逐行复制数据,一共extent.height行 for (size_t k = 0; k < extent.height; k++) memcpy( pData_dst, pImageData + (i * extent.width + (k + j * extent.height) * fullExtent.width) * dataSizePerPixel, dataSizePerRow), pData_dst += dataSizePerRow;//每拷贝一行,pData_dst向后移动一行的数据大小 //取消映射缓冲区 stagingBuffer::UnmapMemory_MainThread(); }
-
在以贴图子块为单元的坐标系中,对于横坐标为i、纵坐标为j的贴图子块,从整张贴图集的左侧边缘到其左边界的像素数为
i * extent.width
,从整张贴图集的顶部到当前被复制行的像素数为k + j * extent.height
,因此(i * extent.width + (k + j * extent.height) * fullExtent.width) * dataSizePerPixel
即是要被拷贝的行的数据的offset。
这个“切割”是在CPU上完成的,上述代码在图像很大,贴图子块数量很多时,可能会有0.001到0.01数量级的耗时。
你也可以将这一步延后到从暂存缓冲区拷贝数据到图像,用vkCmdCopyBufferToImage(...)让物理设备完成这一步,会省点时间。
我并没有在示例代码中提供通过Vulkan命令完成这一步的代码,出于三个原因:
1.后续的代码要改很多地方,会很麻烦(麻烦的是我还要一一解说)。
2.跟解析大尺寸PNG或JPEG的时间相比,0.01秒数量级的耗时可以忽略(读取一张预先渲染好的中日韩汉字字体的PNG图像的耗时可能要按秒计算)。
3.事先准备贴图子块依序纵向排列的图片不就完了(这样根本就不必有多余的耗时)!
作为vkCmdCopyBufferToImage(...)的用例,这里我还是提供下写法:
std::unique_ptr<VkBufferImageCopy[]> regions = std::make_unique<VkBufferImageCopy[]>(layerCount); uint32_t layer = 0; for (size_t j = 0; j < extentInTiles.height; j++) for (size_t i = 0; i < extentInTiles.width; i++) { regions[layer] = { .bufferOffset = (i * extent.width + (j * extent.height) * fullExtent.width) * dataSizePerPixel, .bufferRowLength = fullExtent.width, .bufferImageHeight = fullExtent.height, .imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1 }, .imageExtent = { extent.width, extent.height, 1 } }; layer++; } vkCmdCopyBufferToImage(commandBuffer, stagingBuffer::Buffer_MainThread(), bufferMemory.Buffer(), /*图像的handle*/ , layerCount, regions.get());
将数据扔进暂存缓冲区后,后续的事情会在Create_Internal(...)里完成,调用该函数:
void Create(const uint8_t* pImageData, VkExtent2D extent, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { layerCount = extentInTiles.width * extentInTiles.height; if (layerCount > graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers) { outStream << std::format( "[ texture2dArray ] ERROR\nLayer count is out of limit! Must be less than: {}\n", graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers); return; } if (fullExtent.width % extentInTiles.width || fullExtent.height % extentInTiles.height) outStream << std::format( "[ texture2dArray ] ERROR\nImage not available!\nImage width must be in multiples of {}\nImage height must be in multiples of {}\n", extentInTiles.width, extentInTiles.height); extent.width = fullExtent.width / extentInTiles.width; extent.height = fullExtent.height / extentInTiles.height; size_t dataSizePerPixel = FormatInfo(format_initial).sizePerPixel; size_t imageDataSize = dataSizePerPixel * fullExtent.width * fullExtent.height; if (extentInTiles.width == 1) stagingBuffer::BufferData_MainThread(pImageData, imageDataSize); else { uint8_t* pData_dst = static_cast<uint8_t*>(stagingBuffer::MapMemory_MainThread(imageDataSize)); size_t dataSizePerRow = dataSizePerPixel * extent.width; for (size_t j = 0; j < extentInTiles.height; j++) for (size_t i = 0; i < extentInTiles.width; i++) for (size_t k = 0; k < extent.height; k++) memcpy( pData_dst, pImageData + (i * extent.width + (k + j * extent.height) * fullExtent.width) * dataSizePerPixel, dataSizePerRow), pData_dst += dataSizePerRow; stagingBuffer::UnmapMemory_MainThread(); } Create_Internal(format_initial, format_final, generateMipmap); }
接着填充texture2dArray::Create_Internal(...),大体上跟先前的texture2d::Create_Internal(...)类似。
图层数由1变为layerCount,图像视图的类型变为VK_IMAGE_VIEW_TYPE_2D_ARRAY,此外在需要进行格式转换时,因为先前所述的原因,不必考虑能否为暂存缓冲区创建混叠图像:
void Create_Internal(VkFormat format_initial, VkFormat format_final, bool generateMipmap) { uint32_t mipLevelCount = generateMipmap ? CalculateMipLevelCount(extent) : 1; CreateImageMemory(VK_IMAGE_TYPE_2D, format_final, { extent.width, extent.height, 1 }, mipLevelCount, layerCount); CreateImageView(VK_IMAGE_VIEW_TYPE_2D_ARRAY, format_final, mipLevelCount, layerCount); if (format_initial == format_final) CopyBlitAndGenerateMipmap2d(stagingBuffer::Buffer_MainThread(), imageMemory.Image(), imageMemory.Image(), extent, mipLevelCount, layerCount); else { VkImageCreateInfo imageCreateInfo = { .imageType = VK_IMAGE_TYPE_2D, .format = format_initial, .extent = { extent.width, extent.height, 1 }, .mipLevels = 1, .arrayLayers = layerCount, .samples = VK_SAMPLE_COUNT_1_BIT, .usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT }; vulkan::imageMemory imageMemory_conversion(imageCreateInfo, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); CopyBlitAndGenerateMipmap2d(stagingBuffer::Buffer_MainThread(), imageMemory_conversion.Image(), imageMemory.Image(), extent, mipLevelCount, layerCount); } }
读取多张大小相同的图像文件
填充剩下两个Creat(...)函数,没什么需要解说的,该说的都在注释里:
//从硬盘读取多个大小相同的图像文件 Create(arrayRef<const char* const> filepaths, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { //验证图层数是否满足硬件限制 if (filepaths.Count() > graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers) { outStream << std::format( "[ texture2dArray ] ERROR\nLayer count is out of limit! Must be less than: {}\nFile: {}\n", graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers, filepath); return; } formatInfo formatInfo = FormatInfo(format_initial); auto psImageData = std::make_unique<std::unique_ptr<uint8_t[]>[]>(filepaths.Count()); //遍历文件路径读图 for (size_t i = 0; i < filepaths.Count(); i++) { VkExtent2D extent_currentLayer; psImageData[i] = LoadFile(filepaths[i], extent_currentLayer, formatInfo); //读图成功时进这个if分支 if (psImageData[i]) { //记录首张图的大小 if (i == 0) extent = extent_currentLayer; //如果本图与首张图大小一致,继续读下一张图 if (extent.width == extent_currentLayer.width && extent.height == extent_currentLayer.height) continue; //否则,抛出错误信息并返回 else outStream << std::format( "[ texture2dArray ] ERROR\nImage not available!\nFile: {}\nAll the images must be in same size!\n", filepaths[i]);//直落到一行后的return以返回 } //读图失败(错误信息已在LoadeFile(...)中写明),或图像大小与首张图像不一致时返回 return; } //调用后一个Create(...) Create({ reinterpret_cast<const uint8_t* const*>(psImageData.get()), filepaths.Count() }, extent, format_initial, format_final, generateMipmap); } //从内存读取多个大小相同的图像文件的数据 Create(arrayRef<const uint8_t* const> psImageData, VkExtent2D extent, VkFormat format_initial, VkFormat format_final, bool generateMipmap = true) { //记录图层数 layerCount = psImageData.Count(); //验证图层数是否满足硬件限制 if (layerCount > graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers) { outStream << std::format( "[ texture2dArray ] ERROR\nLayer count is out of limit! Must be less than: {}\nFile: {}\n", graphicsBase::Base().PhysicalDeviceProperties().limits.maxImageArrayLayers, filepath); return; } //记录图像大小 this->extent = extent; //计算单张图像的大小 size_t dataSizePerImage = size_t(FormatInfo(format_initial).sizePerPixel) * extent.width * extent.height; //计算所有图像的大小 size_t imageDataSize = dataSizePerImage * layerCount; //映射暂存缓冲区 uint8_t* pData_dst = static_cast<uint8_t*>(stagingBuffer::MapMemory_MainThread(imageDataSize)); //将所有图像数据拷贝到暂存缓冲区 for (size_t i = 0; i < layerCount; i++) { memcpy(pData_dst, psImageData[i], dataSizePerImage), pData_dst += dataSizePerImage; //取消映射暂存缓冲区 stagingBuffer::UnmapMemory_MainThread(); //调用先前填充的Create_Internal(...) Create_Internal(format_initial, format_final, generateMipmap); }
全部代码请比照VKBase+.h。
采样2D贴图数组
采样2D贴图时,基于Ch7-7 使用贴图中所写的Texture.vert.shader和Texture.frag.shader进行如下修改:
#version 460 #pragma shader_stage(vertex) layout(location = 0) in vec2 i_Position; layout(location = 1) in vec3 i_TexCoord; layout(location = 0) out vec3 o_TexCoord; void main() { gl_Position = vec4(i_Position, 0, 1); o_TexCoord = i_TexCoord; }
#version 460 #pragma shader_stage(fragment) layout(location = 0) in vec3 i_TexCoord; layout(location = 0) out vec4 o_Color; layout(binding = 0) uniform sampler2DArray u_Texture; void main() { o_Color = texture(u_Texture, i_TexCoord); }
注意到texture(...)函数的用法在形式上不变,只是第一个参数是sampler2DArray类型,第二个参数是vec3类型。
采样2D贴图需要一个图层参数,所以UV坐标之外还得要一个W坐标。注意同一个三角形中各顶点的W坐标应当一致,三维图像可以基于W坐标进行插值,但二维图像数组只会有如下结果(对应的贴图集原图见本页面首张配图):
vertex vertices[] = { { { -.5f, -.5f }, { 0, 0, 0 } }, //四个顶点贴图坐标的s分量依次使用0~3 { { .5f, -.5f }, { 1, 0, 1 } }, { { -.5f, .5f }, { 0, 1, 2 } }, { { .5f, .5f }, { 1, 1, 3 } } };