Vulkan学习笔记10—描述符与统一缓冲区

发布于:2025-06-19 ⋅ 阅读:(20) ⋅ 点赞:(0)

一、资源描述符

描述符是着色器自由访问缓冲区和图像等资源的一种方式。

描述符的使用包括三个部分

  • 在管线创建期间指定描述符集布局

  • 从描述符池中分配描述符集

  • 在渲染期间绑定描述符集

描述符集布局指定管线将要访问的资源类型,就像渲染通道指定将要访问的附件类型一样。

描述符集指定将绑定到描述符的实际缓冲区或图像资源,就像帧缓冲指定要绑定到渲染通道附件的实际图像视图一样

二、统一缓冲区对象 (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]);
    }
}
  1. 为每帧渲染创建独立的统一缓冲,支持多帧并行处理;
  2. 使用主机可见且连贯的内存,允许 CPU 直接修改缓冲内容;
  3. 通过内存映射技术,避免了频繁的数据传输,提高性能;
  4. 统一缓冲通常用于存储 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);
    }
}

核心功能:

  1. 从描述符池中分配多个描述符集 (每个帧一个)
  2. 每个描述符集使用相同的布局 (descriptorSetLayout)
  3. 将之前创建的统一缓冲 (uniformBuffers) 绑定到描述符集
  4. 通过描述符集将 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


网站公告

今日签到

点亮在社区的每一天
去签到