Ch5-3 2D贴图数组

本节将简单介绍2D贴图数组的使用方式,并完成相应封装。
阅读本节前请确保已阅读Ch5-2 2D贴图及生成MipmapCh7-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)。
如果不对贴图集进行切割,而是根据贴图子块的所在位置计算纹理坐标进行采样,放大渲染并应用线性滤波,便会发生溢色:

_images/ch5-3-1.png _images/ch5-3-2.png

原因不难理解,溢色是线型滤波的结果,即,采样到贴图子块的边缘时,使用了相邻的另一贴图子块的边缘像素进行线性插值所致(因此渲染左上角的贴图子块时,溢色发生在右侧和底侧)。原图被放大得越多,溢色的效果就越显著。
如果使用贴图数组,每个贴图子块单独构成一个图层,那么只要寻址模式设定为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.shaderTexture.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 } }
};
_images/ch5-3-3.png