组合图像采样器(Combined image sampler)
目录
组合图像采样器(Combined image sampler)
简介(Introduction)
更新描述符(Updating the descriptors)
1. 修改描述符集布局
2. 扩容描述符池
3. 绑定图像和采样器资源到描述符集
纹理坐标(Texture coordinates)
1. 修改顶点结构体
2. 为顶点分配纹理坐标
着色器(Shaders)
1. 顶点着色器(Vertex Shader)
2. 片段着色器(Fragment Shader)
3. 效果验证
简介(Introduction)
我们在教程的统一缓冲区(uniform buffers)部分首次接触了描述符(descriptors)。在本章中,我们将学习一种新的描述符类型:组合图像采样器(combined image sampler)。这种描述符允许着色器通过上一章创建的采样器对象(sampler object)访问图像资源。
值得注意的是,Vulkan 通过不同的描述符类型,为着色器中纹理的访问方式提供了灵活性。本教程中我们将使用组合图像采样器,但 Vulkan 也支持采样器(VK_DESCRIPTOR_TYPE_SAMPLER)和采样图像(VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE)的分离式描述符:
- 使用分离式描述符可以将同一个采样器复用在多个图像上,或通过不同的采样参数访问同一个图像,在存在大量使用相同采样配置的纹理场景中,这种方式效率更高;
- 而组合图像采样器通常更便捷,且由于优化了缓存使用,在部分硬件上能提供更好的性能。
我们将按以下步骤实现:
更新描述符(Updating the descriptors)
1. 修改描述符集布局
打开 createDescriptorSetLayout 函数,添加一个组合图像采样器描述符对应的 VkDescriptorSetLayoutBinding,将其绑定在统一缓冲区之后:
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 为 vk::ShaderStageFlagBits::eFragment,表明该组合图像采样器描述符将在片段着色器中使用(片段颜色的确定在此完成);
- 纹理采样也可在顶点着色器中使用(例如通过高度图动态变形顶点网格),此时需相应修改 stageFlags。
2. 扩容描述符池
由于需要分配组合图像采样器描述符,需扩大描述符池的容量。在 createDescriptorPool 函数中,为 VkDescriptorPoolCreateInfo 添加一个类型为 VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER 的 VkPoolSize:
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 错误,但驱动也可能尝试在内部解决该问题;
- 这意味着在某些情况下(取决于硬件、池大小和分配规模),即使分配超出了描述符池的限制,驱动也可能允许分配成功,而在其他情况下则会失败并返回错误,这种不确定性可能导致程序在部分机器上正常运行,在另一部分机器上崩溃;
- 尽管 Vulkan 将分配责任转移给了驱动,但最佳实践仍是确保分配的描述符数量不超过描述符池创建时指定的对应类型 descriptorCount。未来,若启用最佳实践验证(Best Practice Validation),VK_LAYER_KHRONOS_validation 层会针对此类问题发出警告。
3. 绑定图像和采样器资源到描述符集
打开 createDescriptorSetLayout 函数,在循环中为每个描述符集绑定实际的图像和采样器资源:
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};
std::array descriptorWrites{
vk::WriteDescriptorSet{ .dstSet = descriptorSets[i], .dstBinding = 0, .dstArrayElement = 0, .descriptorCount = 1,
.descriptorType = vk::DescriptorType::eUniformBuffer, .pBufferInfo = &bufferInfo },
vk::WriteDescriptorSet{ .dstSet = descriptorSets[i], .dstBinding = 1, .dstArrayElement = 0, .descriptorCount = 1,
.descriptorType = vk::DescriptorType::eCombinedImageSampler, .pImageInfo = &imageInfo }
};
device.updateDescriptorSets(descriptorWrites, {});
}
- 组合图像采样器的资源通过 VkDescriptorImageInfo 结构体指定(类似统一缓冲区描述符通过 VkDescriptorBufferInfo 指定缓冲区资源),该结构体整合了上一章创建的采样器(textureSampler)、图像视图(textureImageView)和图像布局(需设为 vk::ImageLayout::eShaderReadOnlyOptimal,与纹理图像的最终布局一致);
- 通过 vk::WriteDescriptorSet 更新描述符时,使用 pImageInfo 字段传递图像资源信息(而非统一缓冲区的 pBufferInfo)。
至此,描述符已准备就绪,可被着色器使用!
纹理坐标(Texture coordinates)
纹理映射的一个关键要素是每个顶点的纹理坐标(通常称为 “uv 坐标”),纹理坐标决定了图像如何映射到几何体上。
1. 修改顶点结构体
为 Vertex 结构体添加 vec2 类型的纹理坐标成员,并补充对应的 VkVertexInputAttributeDescription,使顶点着色器能接收纹理坐标输入,并将其传递给片段着色器进行表面插值:
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) )
};
}
};
2. 为顶点分配纹理坐标
为正方形的四个顶点分配纹理坐标,本教程中使用 (0, 0)(左上角)到 (1, 1)(右下角)的坐标,使纹理完整填充正方形:
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 或大于 1 的坐标,观察寻址模式(addressing mode)的效果!
着色器(Shaders)
最后一步是修改着色器,使其从纹理中采样颜色。我们首先需要修改顶点着色器,将纹理坐标传递给片段着色器:
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);
}
你应该会看到类似下方的图像效果。记得重新编译着色器!

纹理坐标可视化(texcoord visualization)
绿色通道代表水平坐标,红色通道代表垂直坐标。四个角落的黑色和黄色区域可以验证:纹理坐标在正方形上从 (0, 0) 到 (1, 1) 实现了正确的插值。由于没有更优的调试方式,在着色器编程中,通过颜色可视化数据就相当于用 printf 调试!
在 Slang 中,Sampler2D 对应组合图像采样器(combined image sampler)描述符。在片段着色器中添加对它的引用:
Sampler2D texture;
针对其他类型的图像,也有对应的 sampler1D 和 sampler3D 类型。务必确保此处使用正确的绑定编号。
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return texture.Sample(vertIn.fragTexCoord);
}
纹理采样通过内置的 Sample 函数实现。该函数接收采样器和坐标作为参数,采样器会在后台自动处理过滤和变换操作。现在运行应用程序,你应该能在正方形上看到纹理了:

正方形上的纹理(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)
现在你已经掌握了在着色器中访问图像的方法!当这项技术与帧缓冲区中可写入的图像结合使用时,会变得非常强大。你可以将这些图像作为输入,实现诸如后期处理(post-processing)、3D 世界内的相机画面显示等炫酷效果。
下一章我们将学习如何添加深度缓冲(depth buffering),以实现场景中对象的正确排序。
C++ code / slang shader / GLSL Vertex shader / GLSL Fragment shader





