组合图像采样器
简介
我们之前在教程的uniform缓冲区部分首次接触了描述符。在本章中,我们将介绍一种新的描述符类型:组合图像采样器。这种描述符使得着色器可以通过采样器对象访问图像资源,就像我们在前一章创建的那样。
值得注意的是,Vulkan通过不同的描述符类型提供了在着色器中访问纹理的灵活性。虽然本教程将使用_组合图像采样器_,但Vulkan也支持独立的采样器描述符(VK_DESCRIPTOR_TYPE_SAMPLER)和采样图像描述符(VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE)。使用独立的描述符可以让你将同一个采样器与多个图像一起使用,或者用不同的采样参数访问同一个图像。这在有许多使用相同采样配置的纹理时可能更高效。然而,组合图像采样器通常更方便,并且在某些硬件上由于优化的缓存使用可能提供更好的性能。
我们将首先修改描述符集布局、描述符池和描述符集,以包含这样的组合图像采样器描述符。之后,我们将向`Vertex`结构添加纹理坐标,并修改片段着色器以从纹理中读取颜色,而不仅仅是插值顶点颜色。
更新描述符
浏览到`createDescriptorSetLayout`函数,并为组合图像采样器描述符添加一个`VkDescriptorSetLayoutBinding`。我们将其简单地放在uniform缓冲区之后的绑定中:
std::array bindings = {
vk::DescriptorSetLayoutBinding( 0, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex, nullptr),
vk::DescriptorSetLayoutBinding( 1, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, nullptr)
};
vk::DescriptorSetLayoutCreateInfo layoutInfo({}, bindings.size(), bindings.data());
确保设置`stageFlags`以表明我们打算在片段着色器中使用组合图像采样器描述符。这是片段颜色将被确定的地方。也可以在顶点着色器中使用纹理采样,例如通过https://en.wikipedia.org/wiki/Heightmap[高度图]动态变形顶点网格。
我们还必须创建一个更大的描述符池,以便为组合图像采样器的分配腾出空间,方法是向`VkDescriptorPoolCreateInfo`添加另一个类型为`VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER`的`VkDescriptorPoolSize`。转到`createDescriptorPool`函数,并修改它以包含此描述符的`VkDescriptorPoolSize`:
std::array poolSize {
vk::DescriptorPoolSize( vk::DescriptorType::eUniformBuffer, MAX_FRAMES_IN_FLIGHT),
vk::DescriptorPoolSize( vk::DescriptorType::eCombinedImageSampler, MAX_FRAMES_IN_FLIGHT)
};
vk::DescriptorPoolCreateInfo poolInfo(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet, MAX_FRAMES_IN_FLIGHT, poolSize);
描述符池不足是验证层不会捕获的问题的一个很好的例子:截至Vulkan 1.1,如果池不够大,vkAllocateDescriptorSets`可能会失败并返回错误代码`VK_ERROR_POOL_OUT_OF_MEMORY,但驱动程序也可能尝试在内部解决问题。这意味着有时(取决于硬件、池大小和分配大小)驱动程序会让我们逃脱超出描述符池限制的分配。其他时候,vkAllocateDescriptorSets`会失败并返回`VK_ERROR_POOL_OUT_OF_MEMORY。如果分配在某些机器上成功但在其他机器上失败,这可能会特别令人沮丧。
由于Vulkan将分配的责任转移给驱动程序,不再严格要求仅分配描述符池创建时相应`descriptorCount`成员指定的某种类型(`VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER`等)的描述符数量。然而,这样做仍然是最佳实践,未来如果你启用https://vulkan.lunarg.com/doc/view/latest/windows/best_practices.html[最佳实践验证],`VK_LAYER_KHRONOS_validation`将警告此类问题。
最后一步是将实际的图像和采样器资源绑定到描述符集中的描述符。转到`createDescriptorSets`函数。
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vk::DescriptorBufferInfo bufferInfo(uniformBuffers[i], 0, sizeof(UniformBufferObject));
vk::DescriptorImageInfo imageInfo( textureSampler, textureImageView, vk::ImageLayout::eShaderReadOnlyOptimal );
...
}
组合图像采样器结构的资源必须在`VkDescriptorImageInfo`结构中指定,就像uniform缓冲区描述符的缓冲区资源在`VkDescriptorBufferInfo`结构中指定一样。这是前一章的对象汇聚的地方。
std::array descriptorWrites{
vk::WriteDescriptorSet( descriptorSets[i], 0, 0, 1, vk::DescriptorType::eUniformBuffer, nullptr, &bufferInfo ),
vk::WriteDescriptorSet( descriptorSets[i], 1, 0, 1, vk::DescriptorType::eCombinedImageSampler, &imageInfo)
};
device.updateDescriptorSets(descriptorWrites, {});
描述符必须用此图像信息更新,就像缓冲区一样。这次我们使用的是`pImageInfo`数组而不是`pBufferInfo`。描述符现在已准备好供着色器使用!
纹理坐标
纹理映射还有一个重要的成分缺失,那就是每个顶点的实际纹理坐标,通常称为“uv坐标”。纹理坐标决定了图像如何实际映射到几何体上。
struct Vertex {
glm::vec2 pos;
glm::vec3 color;
glm::vec2 texCoord;
static vk::VertexInputBindingDescription getBindingDescription() {
return { 0, sizeof(Vertex), vk::VertexInputRate::eVertex };
}
static std::array<vk::VertexInputAttributeDescription, 3> getAttributeDescriptions() {
return {
vk::VertexInputAttributeDescription( 0, 0, vk::Format::eR32G32Sfloat, offsetof(Vertex, pos) ),
vk::VertexInputAttributeDescription( 1, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, color) ),
vk::VertexInputAttributeDescription( 2, 0, vk::Format::eR32G32Sfloat, offsetof(Vertex, texCoord) )
};
}
};
修改`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`的坐标。可以随意尝试不同的坐标。尝试使用低于`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;
}
Sampler2D texture;
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return texture.Sample(vertIn.fragTexCoord);
}
你应该会看到类似下图的图像。记得重新编译着色器!
绿色通道代表水平坐标,红色通道代表垂直坐标。黑色和黄色角确认纹理坐标从`0, 0`到`1, 1`在正方形上正确插值。使用颜色可视化数据是缺乏更好选项时的着色器编程等效于`printf`调试!
采样器在Slang中代表组合图像采样器描述符。在片段着色器中添加对其的引用:
Sampler2D texture;
对于其他类型的图像,有等效的`sampler1D`和`sampler3D`类型。确保在这里使用正确的绑定。
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return texture.Sample(vertIn.fragTexCoord);
}
使用内置的`texture`函数采样纹理。它接受一个`sampler`和坐标作为参数。采样器自动处理背景中的过滤和变换。现在运行应用程序时,你应该会在正方形上看到纹理:
尝试通过将纹理坐标缩放到大于`1`的值来实验寻址模式。例如,以下片段着色器在使用`VK_SAMPLER_ADDRESS_MODE_REPEAT`时会产生下图的结果:
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return texture.Sample(vertIn.fragTexCoord);
}
你也可以使用顶点颜色操作纹理颜色:
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return vec4(vertIn.fragColor * texture.Sample(vertIn.fragTexCoord).rgb, 1.0);
}
我在这里分离了RGB和alpha通道,以便不缩放alpha通道。
你现在知道如何在着色器中访问图像了!当与帧缓冲中写入的图像结合使用时,这是一种非常强大的技术。你可以使用这些图像作为输入来实现很酷的效果,如后处理和3D世界中的相机显示。
在下一章中,我们将学习如何添加深度缓冲以正确排序对象。