一、资源描述符
描述符是着色器自由访问缓冲区和图像等资源的一种方式。
描述符的使用包括三个部分
在管线创建期间指定描述符集布局
从描述符池中分配描述符集
在渲染期间绑定描述符集
描述符集布局指定管线将要访问的资源类型,就像渲染通道指定将要访问的附件类型一样。
描述符集指定将绑定到描述符的实际缓冲区或图像资源,就像帧缓冲指定要绑定到渲染通道附件的实际图像视图一样
二、统一缓冲区对象 (UBO)
UBO 是许多种类型描述符的一种。
将数据复制到 VkBuffer,并通过顶点着色器的统一缓冲区对象描述符访问它,在 VkTypes 中新增类型定义 UBO:
struct UBO {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
修改顶点着色器:
#version 450
// 全局变换矩阵
layout(binding = 0) uniform UBO {
mat4 model; // 模型变换
mat4 view; // 视图变换
mat4 proj; // 投影变换
} ubo;
// 输入属性
layout(location = 0) in vec2 pos; // 顶点位置
layout(location = 1) in vec3 col; // 顶点颜色
// 输出到片段着色器
layout(location = 0) out vec3 fragCol;
void main() {
// 计算最终裁剪空间坐标(包含透视除法)
gl_Position = ubo.proj * ubo.view * ubo.model * vec4(pos, 0.0, 1.0);
fragCol = col; // 传递颜色到片段着色器
}
Uniform、in、out 的声明顺序不影响着色器运行。binding 与 location 作用类似,用于在描述符集布局中定位资源。gl_Position 计算引入了模型 - 视图 - 投影变换链,其结果的 w 分量(通常由透视投影矩阵生成)可能不为 1,这会触发透视除法(NDC = 裁剪坐标 /w),是实现近大远小透视效果的关键。
在创建图形管线之前新增创建描述符布局:
// 创建描述符布局
{
VkDescriptorSetLayoutBinding uboLayoutBinding{};
uboLayoutBinding.binding = 0;
uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
uboLayoutBinding.descriptorCount = 1;
uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
VkDescriptorSetLayout descriptorSetLayout;
VkPipelineLayout pipelineLayout;
VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &uboLayoutBinding;
if (vkCreateDescriptorSetLayout(vkcontext->device, &layoutInfo, nullptr, &descriptorSetLayout) != VK_SUCCESS) {
throw std::runtime_error("创建描述符集布局失败!");
}
}
并在管线布局对象中指定描述符集布局:
// 创建图形管线
{
...
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pushConstantRangeCount = 0;
pipelineLayoutInfo.pSetLayouts = &vkcontext->descriptorSetLayout; // 指定描述符布局
if (vkCreatePipelineLayout(vkcontext->device, &pipelineLayoutInfo, nullptr, &vkcontext->pipelineLayout)
!= VK_SUCCESS) {
throw std::runtime_error("创建管线布局失败!");
}
...
}
在创建索引缓冲后面新增创建统一缓冲:
// 创建统一缓冲
{
VkDeviceSize bufferSize = sizeof(UBO);
vkcontext->uniformBuffers.resize(MAX_CONCURRENT_FRAMES);
vkcontext->uniformBuffersMemory.resize(MAX_CONCURRENT_FRAMES);
vkcontext->uniformBuffersMapped.resize(MAX_CONCURRENT_FRAMES);
for (size_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
createBuffer(vkcontext->physicalDevice,
vkcontext->device,
bufferSize,
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
vkcontext->uniformBuffers[i],
vkcontext->uniformBuffersMemory[i]);
vkMapMemory(vkcontext->device, vkcontext->uniformBuffersMemory[i], 0, bufferSize, 0, &vkcontext->uniformBuffersMapped[i]);
}
}
- 为每帧渲染创建独立的统一缓冲,支持多帧并行处理;
- 使用主机可见且连贯的内存,允许 CPU 直接修改缓冲内容;
- 通过内存映射技术,避免了频繁的数据传输,提高性能;
- 统一缓冲通常用于存储 MVP 矩阵、光照参数等需要频繁更新的着色器常量数据。
在停止渲染后销毁统一缓冲区:
void vkClean(VkContext* vkcontext) {
cleanupSwapChain(vkcontext);
for (size_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
vkDestroyBuffer(vkcontext->device, vkcontext->uniformBuffers[i], nullptr);
vkFreeMemory(vkcontext->device, vkcontext->uniformBuffersMemory[i], nullptr);
}
...
}
为 HelloRect 新增 UBO 属性,新增 update 方法添加 MVP 变换矩阵。
//------------HelloRect.h--------------------
#pragma once
#include <vector>
#include "renderer/VkContext.h"
using namespace renderer;
class HelloRect {
public:
HelloRect();
void update(VkContext&);
const std::vector<Vertex> vertices;
const std::vector<uint16_t> indices;
UBO ubo;
};
//------------HelloRect.cpp------------------
HelloRect::HelloRect()
: vertices({{{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}},
{{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}},
{{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}},
{{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}})
, indices({0, 1, 2, 2, 3, 0}), ubo({}) {}
void HelloRect::update(VkContext& vkcontext) {
static auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = std::chrono::high_resolution_clock::now();
float time = std::chrono::duration<float, std::chrono::seconds::period>(currentTime - startTime).count();
ubo.model = glm::rotate(glm::mat4(1.0f), time * glm::radians(90.0f), glm::vec3(0.0f, 0.0f, 1.0f));
ubo.view = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
ubo.proj = glm::perspective(glm::radians(45.0f), vkcontext.swapChainExtent.width / (float) vkcontext.swapChainExtent.height, 0.1f, 10.0f);
ubo.proj[1][1] *= -1; // GLM 最初是为 OpenGL 设计的,其中裁剪坐标的 Y 坐标是反转的。补偿这种情况的最简单方法是翻转投影矩阵中 Y 轴缩放因子的符号。如果不这样做,则图像将倒置渲染。
}
int main() {
initWindow();
vkcontext.window = window;
HelloRect app;
vkcontext.vertexData = (void*)app.vertices.data();
vkcontext.vertexNum = app.vertices.size();
vkcontext.vertexBufferSize = app.vertices.size() * sizeof(Vertex);
vkcontext.indicesData = (void*)app.indices.data();
vkcontext.indicesNum = app.indices.size();
vkcontext.indexBufferSize = app.indices.size() * sizeof(app.indices[0]);
vkcontext.ubo = &app.ubo;
if (!vkInit(&vkcontext)) {
throw std::runtime_error("Vulkan 初始化失败!");
}
try {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
app.update(vkcontext);
vkRender(&vkcontext);
}
...
}
三、描述符池和描述符集
为每个 VkBuffer 资源创建一个描述符集,以将其绑定到统一缓冲区描述符。描述符集不能直接创建,它们必须像命令缓冲区一样从池中分配,下面紧接着统一缓冲后面添加创建描述池代码:
// 创建描述符池
{
VkDescriptorPoolSize poolSize{};
poolSize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
poolSize.descriptorCount = static_cast<uint32_t>(MAX_CONCURRENT_FRAMES);
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.poolSizeCount = 1;
poolInfo.pPoolSizes = &poolSize;
poolInfo.maxSets = static_cast<uint32_t>(MAX_CONCURRENT_FRAMES);
if (vkCreateDescriptorPool(vkcontext->device, &poolInfo, nullptr, &vkcontext->descriptorPool) != VK_SUCCESS) {
throw std::runtime_error("failed to create descriptor pool!");
}
}
// 创建描述符集 - 连接着色器与资源(如统一缓冲、纹理等)的桥梁
{
// 为每个并发帧创建相同布局的描述符集
std::vector<VkDescriptorSetLayout> layouts(MAX_CONCURRENT_FRAMES, vkcontext->descriptorSetLayout);
// 描述符集分配信息
VkDescriptorSetAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; // 结构体类型
allocInfo.descriptorPool = vkcontext->descriptorPool; // 从哪个描述符池分配
allocInfo.descriptorSetCount = static_cast<uint32_t>(MAX_CONCURRENT_FRAMES); // 分配数量
allocInfo.pSetLayouts = layouts.data(); // 使用的布局数组
// 调整容器大小存储描述符集句柄
vkcontext->descriptorSets.resize(MAX_CONCURRENT_FRAMES);
// 分配描述符集
if (vkAllocateDescriptorSets(vkcontext->device, &allocInfo, vkcontext->descriptorSets.data()) != VK_SUCCESS) {
throw std::runtime_error("分配描述符集失败!");
}
// 为每个描述符集更新缓冲区信息
for (size_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
// 描述统一缓冲的信息
VkDescriptorBufferInfo bufferInfo{};
bufferInfo.buffer = vkcontext->uniformBuffers[i]; // 指定使用的缓冲
bufferInfo.offset = 0; // 偏移量(从缓冲起始位置)
bufferInfo.range = sizeof(UBO); // 范围(使用整个UBO大小)
// 描述如何更新描述符集
VkWriteDescriptorSet descriptorWrite{};
descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; // 结构体类型
descriptorWrite.dstSet = vkcontext->descriptorSets[i]; // 目标描述符集
descriptorWrite.dstBinding = 0; // 绑定点(对应着色器中的layout(binding=0))
descriptorWrite.dstArrayElement = 0; // 数组元素索引(若有多个)
descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; // 描述符类型
descriptorWrite.descriptorCount = 1; // 描述符数量
descriptorWrite.pBufferInfo = &bufferInfo; // 缓冲信息指针
// 更新描述符集 - 将统一缓冲与描述符集绑定
vkUpdateDescriptorSets(vkcontext->device, 1, &descriptorWrite, 0, nullptr);
}
}
核心功能:
- 从描述符池中分配多个描述符集 (每个帧一个)
- 每个描述符集使用相同的布局 (descriptorSetLayout)
- 将之前创建的统一缓冲 (uniformBuffers) 绑定到描述符集
- 通过描述符集将 CPU 更新的数据传递给 GPU 着色器
关键概念
- 描述符集 (Descriptor Set):是一组描述符的集合,描述符是着色器访问资源的抽象
- 描述符池 (Descriptor Pool):预分配的内存池,用于高效创建描述符集
- 描述符布局 (Descriptor Set Layout):定义了描述符集的结构,对应着色器中的 layout 声明
- 绑定点 (Binding):着色器中使用 layout (binding=X) 指定的资源位置
在 recordCommandBuffer 函数的 vkCmdDrawIndexed 调用前添加 vkCmdBindDescriptorSets 将每一帧的正确描述符集绑定到着色器中的描述符。
void recordCommandBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex, VkContext* vkcontext) {
VkCommandBufferBeginInfo beginInfo{};
...
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, vkcontext->pipelineLayout, 0, 1, &vkcontext->descriptorSets[vkcontext->currentFrame], 0, nullptr);
vkCmdDrawIndexed(commandBuffer, vkcontext->indicesNum, 1, 0, 0, 0);
vkCmdEndRenderPass(commandBuffer);
if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
throw std::runtime_error("录制命令缓冲失败!");
}
}
VkContext中 新增成员:
std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
UBO* ubo;
VkDescriptorPool descriptorPool;
std::vector<VkDescriptorSet> descriptorSets;
现在构建运行直接黑屏,原因是投影矩阵中进行了 Y 翻转,顶点现在是以逆时针顺序而不是顺时针顺序绘制的。这会导致背面剔除生效,并阻止任何几何图形被绘制。要解决背面剔除导致的渲染问题,只需在图形管线配置中修改光栅化状态:
rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
现在构建运行效果:
内存对齐要求
Vulkan 希望你结构中的数据以一种特定的方式在内存中对齐,例如
标量必须按 N 对齐(对于 32 位浮点数,N = 4 字节)。
一个
vec2
必须按 2N 对齐(= 8 字节)一个
vec3
或vec4
必须按 4N 对齐(= 16 字节)一个嵌套结构必须按其成员的基本对齐方式对齐,并向上舍入到 16 的倍数。
一个
mat4
矩阵必须与一个vec4
具有相同的对齐方式。
始终明确指定对齐方式,这样,你就不会被对齐错误引起的奇怪症状所迷惑。
struct UniformBufferObject {
alignas(16) glm::mat4 model;
alignas(16) glm::mat4 view;
alignas(16) glm::mat4 proj;
};
当前代码分支: 08_uniformbuffer