狸村 Mystic Web · 狸村
目录

引言

我们在教程的统一缓冲部分首次了解了描述符。 在本章中,我们将研究一种新型描述符:组合图像采样器(combined image sampler)。 这种描述符使得着色器能够通过像我们上一章中创建的采样器对象来访问图像资源。

值得注意的是,Vulkan 通过不同的描述符类型为着色器中如何访问纹理提供了灵活性。虽然在本教程中我们将使用 组合图像采样器,但 Vulkan 也支持采样器(vk::DescriptorType::eSampler)和采样图像(vk::DescriptorType::eSampledImage)的单独描述符。使用单独的描述符允许你将同一个采样器与多个图像复用,或者使用不同的采样参数访问同一个图像。在你有很多使用相同采样配置的纹理的情况下,这可以更高效。然而,组合图像采样器通常更方便,并且由于优化的缓存使用,在某些硬件上可以提供更好的性能。

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

更新描述符

浏览到 createDescriptorSetLayout 函数,并为组合图像采样器描述符添加一个 VkDescriptorSetLayoutBinding。 我们只需将其放在统一缓冲之后的绑定中:

std::array<vk::DescriptorSetLayoutBinding, 2> bindings{
    {{.binding = 0, .descriptorType = vk::DescriptorType::eUniformBuffer, .descriptorCount = 1, .stageFlags = vk::ShaderStageFlagBits::eVertex},
     {.binding = 1, .descriptorType = vk::DescriptorType::eCombinedImageSampler, .descriptorCount = 1, .stageFlags = vk::ShaderStageFlagBits::eFragment}}};

vk::DescriptorSetLayoutCreateInfo layoutInfo{.bindingCount = static_cast<uint32_t>(bindings.size()), .pBindings = bindings.data()};

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

我们还必须创建一个更大的描述符池,通过向 vk::DescriptorPoolCreateInfo 添加另一个类型为 vk::DescriptorType::eCombinedImageSamplervk::PoolSize,为组合图像采样器的分配留出空间。 转到 createDescriptorPool 函数,并将其修改为包含一个用于此描述符的 vk::DescriptorPoolSize

std::array<vk::DescriptorPoolSize, 2> poolSize{{{.type = vk::DescriptorType::eUniformBuffer, .descriptorCount = MAX_FRAMES_IN_FLIGHT},
                                                {.type = vk::DescriptorType::eCombinedImageSampler, .descriptorCount = MAX_FRAMES_IN_FLIGHT}}};
vk::DescriptorPoolCreateInfo          poolInfo{.flags         = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet,
                                               .maxSets       = MAX_FRAMES_IN_FLIGHT,
                                               .poolSizeCount = static_cast<uint32_t>(poolSize.size()),
                                               .pPoolSizes    = poolSize.data()};

描述符池不足是验证层不会捕获的问题的一个典型例子:自 Vulkan 1.1 起,如果池不够大,vk::raii::Device::allocateDescriptorSets 可能会失败并抛出 vk::OutOfPoolMemoryError 异常,但驱动程序也可能尝试在内部解决该问题。 这意味着有时(取决于硬件、池大小和分配大小)驱动程序会允许我们的分配超出描述符池的限制。 其他时候,vk::raii::Device::allocateDescriptorSets 会失败并抛出 vk::OutOfPoolMemoryError 异常。 如果在某些机器上分配成功但在其他机器上失败,这尤其令人沮丧。

由于 Vulkan 将分配的责任转移给了驱动程序,不再严格要求只分配与描述符池创建时相应的 descriptorCount 成员所指定数量的某种类型(vk::DescriptorType::eCombinedImageSampler 等)的描述符。 然而,这仍然是最佳实践,并且将来,如果你启用 Best Practice Validation,VK_LAYER_KHRONOS_validation 会对此类问题发出警告。

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

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++)
{
    vk::DescriptorBufferInfo bufferInfo{.buffer = uniformBuffers[i], .offset = 0, .range = sizeof(UniformBufferObject)};
    vk::DescriptorImageInfo  imageInfo{.sampler = textureSampler, .imageView = textureImageView, .imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal};

    ...
}

组合图像采样器的资源必须在 vk::DescriptorImageInfo 结构体中指定,就像统一缓冲描述符的缓冲资源在 vk::DescriptorBufferInfo 结构体中指定一样。 这就是前一章中的对象汇聚在一起的地方。

std::array<vk::WriteDescriptorSet, 2> descriptorWrites{{{.dstSet          = descriptorSets[i],
                                                         .dstBinding      = 0,
                                                         .dstArrayElement = 0,
                                                         .descriptorCount = 1,
                                                         .descriptorType  = vk::DescriptorType::eUniformBuffer,
                                                         .pBufferInfo     = &bufferInfo},
                                                        {.dstSet          = descriptorSets[i],
                                                         .dstBinding      = 1,
                                                         .dstArrayElement = 0,
                                                         .descriptorCount = 1,
                                                         .descriptorType  = vk::DescriptorType::eCombinedImageSampler,
                                                         .pImageInfo      = &imageInfo}}};
device.updateDescriptorSets(descriptorWrites, {});

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

纹理坐标

纹理映射还有一个重要的组成部分缺失了,那就是每个顶点的实际纹理坐标,通常称为“uv 坐标”。 纹理坐标决定了图像实际如何映射到几何体上。

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

    static vk::VertexInputBindingDescription getBindingDescription()
    {
        return { 0, sizeof(Vertex), vk::VertexInputRate::eVertex };
    }

    static std::array<vk::VertexInputAttributeDescription, 2> getAttributeDescriptions()
    {
        return {{{.location = 0, .binding = 0, .format = vk::Format::eR32G32Sfloat, .offset = offsetof(Vertex, pos)},
                 {.location = 1, .binding = 0, .format = vk::Format::eR32G32Sfloat, .offset = offsetof(Vertex, texCoord)}}};
    }
};

修改 Vertex 结构体,使其包含一个 vec2 作为纹理坐标。颜色不再使用,可以移除。 确保还添加了一个 vk::VertexInputAttributeDescription,以便我们可以在顶点着色器中访问纹理坐标作为输入。 这是为了能够将它们传递给片段着色器,以便在正方形表面进行插值所必需的。

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 的坐标,看看寻址模式的效果!

着色器

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

struct VSInput {
    float2 inPos;
    float3 inColor;
    float2 inTexCoord;
};

struct UniformBuffer {
    float4x4 model;
    float4x4 view;
    float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;

struct VSOutput
{
    float4 pos : SV_Position;
    float3 fragColor;
    float2 fragTexCoord;
};

[shader("vertex")]
VSOutput vertMain(VSInput input) {
    VSOutput output;
    output.pos = mul(ubo.proj, mul(ubo.view, mul(ubo.model, float4(input.inPos, 0.0, 1.0))));
    output.fragColor = input.inColor;
    output.fragTexCoord = input.inTexCoord;
    return output;
}

[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
   return float4(vertIn.fragTexCoord, 0.0, 1.0);
}

你应该会看到类似下面的图像。 记得重新编译着色器!

texcoord visualization

绿色通道表示水平坐标,红色通道表示垂直坐标。 黑色和黄色角确认纹理坐标在正方形上从 0, 01, 1 正确插值。 使用颜色可视化数据是着色器编程中相当于 printf 调试的方法,因为没有更好的选择!

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

Sampler2D texture;

还有其他类型的图像对应的 sampler1Dsampler3D 类型。 确保在这里使用正确的绑定。

[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
   return texture.Sample(vertIn.fragTexCoord);
}

纹理使用内置的 texture 函数进行采样。 它接受一个 sampler 和坐标作为参数。 采样器自动在后台处理过滤和变换。 现在当你运行应用程序时,你应该在正方形上看到纹理:

texture on square

尝试通过将纹理坐标缩放到大于 1 的值来实验寻址模式。 例如,当使用 VK_SAMPLER_ADDRESS_MODE_REPEAT 时,以下片段着色器产生下图中的结果:

[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
   return texture.Sample(vertIn.fragTexCoord);
}
texture on square repeated

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

[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
   return float4(vertIn.fragColor * texture.Sample(vertIn.fragTexCoord).rgb, 1.0);
}

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

texture on square colorized

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

在下一章中,我们将学习如何添加深度缓冲以正确排序对象。

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。