Vulkan基本概念
Dionysen

Vulkan的一些基本概念。

基本概念

Instance

App与vulkan之间的桥梁,创建一个Instance即创建一个vulkan对象,之后才可以进行更多操作。

Physical Device

物理设备,通常来说是一个显卡(GPU),也可以是其他设备,如NPU、DSP等。

Device

相当于一个在App中的physical device实例,App可以创建多个Device与物理设备交互和管理资源;类比OpenGL,相等于Context。

QueueFamily

每一个物理设备的功能不同,如显卡有计算和图形渲染功能,而NPU只能计算,DSP可以解码,这些功能都由物理设备的QueueFamily体现,每一种功能对应一种QueueFamily。

App与vulkan交互的逻辑就是,通过QueueFamily创建处对应的Queue,然后将Command推入Queue中,vulkan会把这些command交给设备进行处理。

Queue

Queue是vulkan很重要的概念,可以理解为App与设备之间通信的队列。

每种Queue可以接受不同的Command,取决于它属于哪一个QueueFamily。

在创建Queue时,指定对应的family的index,便可以创建属于该family的Queue。

Image、Buffer

都属于内存空间对象。不同的是Image表示特定用途的对象,如颜色缓冲,纹理,深度等。Buffer仅仅代表原始数据缓存。

Memory

一块内存。可以是CPU内存,也可以是GPU内存(显存)。创建一个Buffer或Image这样的内存对象,必须给他们分配内存,即Memory。

ImageView

Image具有明确的用途,需要让vulkan知道如何使用Image。

因此要告诉vulkan这个Image的类型格式等属性,ImageView用以描述这些属性,vulkan不会直接操作Image,而是通过ImageView对象来操作Iamge。

CommandBuffer

命令缓冲,

CommandPool

用来分配CommandBuffer。由于每一个Queue支持的Command不同(用途不同),在创建对应Queue的CommandPool时,也应该指定与创建Queue时一样的QueueFamily的Index。

Pipeline

图形渲染管线,与OpenGL中的概念相似。顶点着色器->细分着色器->几何着色器->光栅化着色器->片段着色器。OpenGL中固有这些,而vulkan需要手动创建。(也因此可以看出vulkan的可编程的程度极深)

Framebuffer

帧缓冲,即渲染目标对象。Framebuffer需要关联不同的Image,这些Image起到不同的作用,颜色缓冲、深度缓冲、模板缓冲等。

RenderPass

可以理解为Pipeline的输入参数,最终要的输入参数是Framebuffer。

Discriptor

Discriptor是单个资源的抽象句柄(如纹理、缓冲)。

DiscriptorSet是一组描述符集合,对应着色器中的set绑定组。

DescriptorPool(描述符池) 是用于高效分配和管理 描述符集(DescriptorSet) 的核心对象。它类似于内存池的概念,主要作用是通过预分配资源减少动态内存分配的开销,从而提升渲染性能。

Descriptor预先分配一定数量和类型的描述符(如UniformBuffer、StorageBuffer、纹理、采样器等),后续从池中分配描述符集,避免频繁的全局内存操作和同步开销。

1)通过描述符集布局定义资源绑定规则;2)从描述符池分配描述符集;3)更新描述符集指向的资源;4)绑定描述符集到管线供着色器使用。

Submit

CommandBuffer不能直接推入Queue中,因为多个CommandBuffer可能需要进行同步、排他等设置,因此需要将CommandBuffer和同步、排他对象一起放到同一个Submit对象中,然后将Submit推入Queue中。这样vulkan可以根据Submit中的对象来安排CommandBuffer的执行顺序。

常用的控制CommandBuffer之间、CPU-GPU、GPU-GPU之间的同步或排他对象由VkEvent、VkSemaphore和VkFence。

UniformBuffer/StorageBuffer

这两种buffer都是用于着色器与CPU之间传递的结构化数据。

UniformBuffer是一种只读缓冲,用于传递全局、低频更新的数据(如MVP矩阵、灯光参数等)。绘制期间,在着色器中无法修改。需要显式对齐,std140.

StorageBuffer是一种可读写缓冲,用于传递大量结构化数据(如顶点数据、粒子状态、计算着色器结果)。支持动态修改和原子操作。对齐灵活,std340.

特性 Uniform Buffer Storage Buffer
访问权限 只读 可读写(支持原子操作)
数据规模 适合小数据(KB 级) 适合大数据(MB/GB 级)
内存布局 std140(严格对齐) std430(更灵活对齐)
性能优化 低延迟,专用缓存 高带宽,适合批量操作
典型用途 矩阵、全局参数 粒子系统、计算着色器、通用数据结构
Vulkan 标志 VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT VK_BUFFER_USAGE_STORAGE_BUFFER_BIT

内存布局规则

Uniform Buffer(std140)
  • 基础类型对齐:标量(如 float)按 4 字节对齐,向量按 4N 字节对齐(如 vec3 对齐到 16 字节

  • )。

  • 结构体内存:成员按声明顺序对齐,整体大小填充为 16 的倍数。

    // C++ 结构体(需手动填充)
    struct UniformBufferObject {
    alignas(16) glm::mat4 model; // 16 字节对齐
    alignas(16) glm::mat4 view;
    alignas(16) glm::mat4 proj;
    };
Storage Buffer(std430)
  • 更宽松对齐:标量和向量按自然大小对齐(如 vec3 对齐到 12 字节)。

  • 数组支持:动态数组无需预定义大小(在着色器中声明为positions[]

    // C++ 结构体(无需严格填充)
    struct Particle {
    glm::vec4 position; // 16 字节对齐
    glm::vec4 velocity; // 自然对齐
    };

ComputeShader

属于计算管线的核心部分,它不处理顶点、片段或几何数据,而是通过工作组并行执行用户定义的计算任务。无固定输入输出,完全由开发者定义,通过工作组和线程手动划分并行任务,无需绑定RenderPass或FrameBuffer(独立于渲染管线)。

一般用于预处理或后处理,可以与图形管线无缝协作。可以用于图片模糊、锐化、粒子系统、光线追踪、几何处理、科学计算等。

渲染器

双缓冲命令队列

维护两个命令队列,一个队列用于接收新的渲染命令,另一个队列用于执行渲染命令,通过SwapQueues在两个队列之间切换。这种设计可以实现渲染命令的并行处理:当GPU执行一个队列命令时,CPU可以同时向另一个队列中提交新的命令。

Placement new

Placement new是C++中的一种特殊的new操作符,它允许我们在已分配的内存空间中构造对象。与普通的new操作符不同,placement new不分配内存,只在指定的内存位置上调用构造函数。

可以在实时系统中避免动态内存分配。

不需要使用delete,因为内存不是new分配的。

void* memory = // 某个内存位置
new (memory) Type(constructor_args...);

vkDeviceWaitIdle

该函数会阻塞 CPU 线程,直到设备关联的所有队列(图形、计算、传输等)全部完成当前提交的任务并进入空闲状态。

函数 作用范围 使用场景
vkQueueWaitIdle 单个指定队列 等待特定队列任务完成(如图形队列)
vkDeviceWaitIdle 设备所有队列 全局同步或设备级资源管理
vkFence 异步信号通知 非阻塞式等待特定任务完成

渲染流程

  1. 创建Instance对象
  2. 通过Intance列举所有Physical Device
  3. 从Physical Device中挑选一个使用,并创建其Device对象
  4. 通过Device对象创建支持图形指令的Queue
  5. 通过Device创建Pipeline
    1. 创建Shader
    2. 创建RenderPass
    3. 创建Framebuffer
      1. 创建对应的Image,分配内存
  6. 通过Device创建CommandPool
  7. 通过CommandPool分配CommandBuffer
  8. 主循环
    1. 使用渲染命令填充CommandBuffer
    2. 将填充后的CommandBuffer和排他、同步对象封装到Submit中
    3. 将Submit推入Queue
  9. 回收资源

渲染命令的提交

这是一种封装,将所有与渲染相关的任务封装成一个函数(命令),提交到队列中,队列维护一个头指针和动指针,和一个buffer,此buffer中存储提交的函数指针。

后面在渲染进程中执行的就是此buffer(队列)中的命令(指针所指的函数)。

// 提交渲染命令,FuncT为函数类型,使用std::forward<FuncT>(func),可以完美转发FuncT类型的函数
// 所谓渲染命令,就是将FuncT类型的函数包装成一个命令,然后提交到队列中,渲染线程会执行这个命令
// 提交可以分别提交,执行则是一次性执行所有命令
template <typename FuncT> static void Submit(FuncT&& func)
{
// 编译时检查FuncT是否可调用
static_assert(std::is_invocable<FuncT>::value, "Function must be invocable");
// 定义渲染命令执行函数,传入void*,执行FuncT类型的函数

// 这里只是包装FuncT类型的函数,并调用析构函数
auto renderCmd = [](void* ptr) {
auto pFunc = (FuncT*)ptr; // 将通用指针类型(void*)转换为具体类型(FuncT*)
(*pFunc)(); // 调用FuncT类型的函数
pFunc->~FuncT(); // 调用析构函数
};

// 在命令队列中分配空间
auto storageBuffer = GetRenderCommandQueue().Allocate(renderCmd, sizeof(func));
// 在分配的空间中初始化FuncT类型的函数,这里使用了placement
// new,不需要调用delete,因为空间并不是new出来的,而是命令队列在创建队列时new的,然后分配给renderCmd
new (storageBuffer) FuncT(std::forward<FuncT>(func));
}

双缓冲渲染命令队列

最开始是一个空渲染帧,最开始当然什么命令也没有,执行0个命令,然后开始主循环。

循环最开始是先执行渲染队列0中已提交的命令,第一个主循环当然依然没有命令,此时主线程同时向渲染队列1中提交命令,渲染线程渲染完毕后等待,主线程提交命令后进入下一循环。

之后的循环:主线程在渲染线程渲染完毕后开始进入下一帧,切换命令队列,唤醒渲染线程渲染上一个队列的命令,同时提交当前队列的命令;依然是渲染线程渲染完毕后阻塞等待,主线程提交完毕后进入下一循环。

单线程模式下: 主线程提交命令与渲染线程执行命令不会同时进行,而是先将上一帧的渲染完成,然后再提交命令,如此往复。

[11:14:20] [53480] [info] [MainThread] SwapQueues: 0
[11:14:20] [53480] [trace] [MainThread] Kicking render thread (start rendering) ...
[11:14:20] [53480] [trace] [MainThread] Waiting for render thread to complete rendering...
[11:14:20] [49268] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes # 第一个渲染帧
[11:14:20] [49268] [trace] [RenderThread] Render complete
[11:14:20] [49268] [trace] [RenderThread] Waiting for kick

[11:14:20] [53480] [warning] ======== Begin Loop ========
[11:14:20] [53480] [trace] [MainThread] Waiting for render thread to complete rendering...
[11:14:20] [53480] [info] [MainThread] SwapQueues: 1
[11:14:20] [53480] [trace] [MainThread] Kicking render thread (start rendering) ...
[11:14:20] [53480] [info] [MainThread] RenderCommandQueue::Allocate -- 1 commands, 20 bytes # 第而二个渲染帧
[11:14:20] [49268] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[11:14:20] [49268] [trace] [RenderThread] Render complete
[11:14:20] [49268] [trace] [RenderThread] Waiting for kick
[11:14:20] [53480] [warning] ======== End Loop ========

[11:14:20] [53480] [warning] ======== Begin Loop ========
[11:14:20] [53480] [trace] [MainThread] Waiting for render thread to complete rendering...
[11:14:20] [53480] [info] [MainThread] SwapQueues: 0
[11:14:20] [53480] [trace] [MainThread] Kicking render thread (start rendering) ...
[11:14:20] [53480] [info] [MainThread] RenderCommandQueue::Allocate -- 1 commands, 20 bytes
[11:14:20] [49268] [trace] [RenderThread] RenderCommandQueue::Execute -- 1 commands, 20 bytes
[11:14:20] [53480] [warning] ======== End Loop ========

[11:14:20] [53480] [warning] ======== Begin Loop ========
[11:14:20] [53480] [trace] [MainThread] Waiting for render thread to complete rendering...
[11:14:20] [49268] [trace] [RenderThread] Render complete
[11:14:20] [49268] [trace] [RenderThread] Waiting for kick
[11:14:20] [53480] [info] [MainThread] SwapQueues: 1
[11:14:20] [53480] [trace] [MainThread] Kicking render thread (start rendering) ...
[11:14:20] [53480] [info] [MainThread] RenderCommandQueue::Allocate -- 1 commands, 20 bytes
[11:14:20] [49268] [trace] [RenderThread] RenderCommandQueue::Execute -- 1 commands, 20 bytes
[11:14:20] [53480] [warning] ======== End Loop ========

渲染线程的终止

当程序终止,要妥善处理线程的终止,避免死锁导致渲染进程Join后无法退出的问题。

对比以下个log:

[14:16:22] [15200] [warning] ======== Begin Loop ========
[14:16:22] [15200] [info] [MainThread] Waiting for render thread to complete rendering...
[14:16:22] [2164] [trace] [RenderThread] Render complete, renderthread state: true
[14:16:22] [2164] [trace] [RenderThread] <RenderFunc> Waiting for kick
[14:16:22] [15200] [info] [MainThread] SwapQueues: 1
[14:16:22] [15200] [info] [MainThread] Kicking render thread (start rendering) ...
[14:16:22] [15200] [info] [MainThread] RenderCommandQueue::Allocate -- 1 commands, 20 bytes
[14:16:22] [15200] [warning] ======== End Loop ========
[14:16:22] [15200] [error] [RenderThread] Terminating render thread...
[14:16:22] [15200] [error] [RenderThread] Renderfunc stoped before.
[14:16:22] [15200] [error] [RenderThread] Renderfunc stoped.
[14:16:22] [15200] [info] [MainThread] SwapQueues: 0
[14:16:22] [15200] [info] [MainThread] Kicking render thread (start rendering) ...
[14:16:22] [15200] [info] [MainThread] Waiting for render thread to complete rendering...
[14:16:22] [2164] [trace] [RenderThread] RenderCommandQueue::Execute -- 1 commands, 20 bytes
[14:16:22] [2164] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[14:16:22] [2164] [trace] [RenderThread] Render complete, renderthread state: false
[14:16:22] [2164] [error] [RenderThread] <RenderFunc> exit loop.
[14:16:22] [15200] [error] [RenderThread] Terminated.
[14:16:22] [15200] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[14:16:22] [15200] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[14:16:22] [15200] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[14:17:54] [47488] [warning] ======== Begin Loop ========
[14:17:54] [47488] [info] [MainThread] Waiting for render thread to complete rendering...
[14:17:54] [44996] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[14:17:54] [44996] [trace] [RenderThread] Render complete, renderthread state: true
[14:17:54] [44996] [trace] [RenderThread] Render complete, renderthread state: true
[14:17:54] [44996] [trace] [RenderThread] <RenderFunc> Waiting for kick
[14:17:54] [47488] [info] [MainThread] SwapQueues: 1
[14:17:54] [47488] [info] [MainThread] Kicking render thread (start rendering) ...
[14:17:54] [47488] [info] [MainThread] RenderCommandQueue::Allocate -- 1 commands, 20 bytes
[14:17:54] [47488] [warning] ======== End Loop ========
[14:17:54] [44996] [trace] [RenderThread] RenderCommandQueue::Execute -- 1 commands, 20 bytes
[14:17:54] [44996] [trace] [RenderThread] RenderCommandQueue::Execute -- 0 commands, 0 bytes
[14:17:54] [47488] [error] [RenderThread] Terminating render thread...
[14:17:54] [47488] [error] [RenderThread] Renderfunc stoped before.
[14:17:54] [47488] [error] [RenderThread] Renderfunc stoped.
[14:17:54] [44996] [trace] [RenderThread] Render complete, renderthread state: true
[14:17:54] [47488] [info] [MainThread] SwapQueues: 0
[14:17:54] [47488] [info] [MainThread] Kicking render thread (start rendering) ...
[14:17:54] [47488] [info] [MainThread] Waiting for render thread to complete rendering...
[14:17:54] [44996] [trace] [RenderThread] Render complete, renderthread state: false
[14:17:54] [44996] [error] [RenderThread] <RenderFunc> exit loop.

这里有一个潜在的问题,如果在m_IsRunning被设置为false之后,渲染线程先退出循环,这里再进行Pump,就会导致主线程无限等待渲染结束,而渲染线程则永远也无法结束(因为根本无法开始————循环已经结束了)

解决方法是先等待渲染线程空闲,开启下一帧,Kick渲染使其开始渲染,此时立刻关闭循环,并入主线程,等待渲染线程的结束,主线程就可以顺利结束了

三缓冲渲染(多帧并行)

vkFence

栅栏是一种同步原语,可用于将依赖项从队列插入到主机。栅栏有两种状态 - 已发出信号和未发出信号。栅栏可在执行队列提交命令时发出信号。可以使用 vkResetFences 在主机上取消栅栏信号。主机可以使用 vkWaitForFences 命令等待栅栏,并且可以使用 vkGetFenceStatus 查询当前状态。

vkWaitForFences
VkResult vkWaitForFences(
VkDevice device, // 拥有此栏栅的设备
uint32_t fenceCount, // 需要等待的栏栅数量
const VkFence* pFences, // 指向栏栅句柄的指针
VkBool32 waitAll, // 是否等待所有栏栅
uint64_t timeout); // 超时时间

调用后CPU阻塞,直到GPU完成上一帧的工作并发出Fence信号或者达到超时时间。

如果GPU处理速度跟不上CPU提交速度,会导致CPU等待,这可能会影响整体性能,通常通过使用多个”frame in flight”(多帧并行)来减少等待,使用多个交替的Fence来实现CPU与GPU的并行工作。

// 等待上一帧完成
vkWaitForFences(device, 1, &m_WaitFences[m_CurrentFrameIndex], VK_TRUE, UINT64_MAX);
// 获取新的图像
fpAcquireNextImageKHR(..., m_ImageAvailableSemaphores[m_CurrentFrameIndex], ...);
// 提交渲染命令
vkQueueSubmit(..., m_RenderFinishedSemaphores[m_CurrentFrameIndex], ...);
多帧并行
// 每个frame都有独立的同步对象
m_ImageAvailableSemaphores.resize(framesInFlight);
m_RenderFinishedSemaphores.resize(framesInFlight);
m_WaitFences.resize(framesInFlight);

与双缓冲命令队列不同,命令队列只关注渲染命令的记录(CPU中)和执行(GPU中),而FramesInFlight要管理所有帧相关的资源。

KHR后缀

在Vulkan中,带有KHR后缀和不带KHR后缀的函数主要区别在于:

  1. 扩展与核心功能:

    • vkWaitSemaphoresKHR: 是KHR扩展的一部分,最初作为扩展功能引入
    • vkWaitSemaphores: 是Vulkan核心功能的一部分,在后续版本中被纳入标准
  2. 可用性:

    • KHR版本需要检查设备是否支持该扩展
    • 非KHR版本在支持该版本的Vulkan实现中直接可用
  3. 使用方式:

    // KHR扩展版本需要先启用扩展
    VkDeviceCreateInfo createInfo = {};
    createInfo.ppEnabledExtensionNames = { "VK_KHR_timeline_semaphore" };

    // 核心版本直接使用
    vkWaitSemaphores(device, ...);
  4. 版本要求:

    • KHR版本通常在较早的Vulkan版本中就可用
    • 非KHR版本需要更新的Vulkan版本

一般建议:

  1. 如果需要支持较老的Vulkan版本,使用KHR版本
  2. 如果使用较新的Vulkan版本,优先使用非KHR版本
  3. 有时可能需要根据运行时检测来决定使用哪个版本

注意:这个规则适用于Vulkan中所有带KHR后缀和不带KHR后缀的函数对。KHR表示这个功能最初是由Khronos Group(Vulkan的开发组织)作为扩展引入的。

描述符集

描述符池,Vulkan中的描述符集不能直接创建,只能从特定的缓冲池中分配,这个缓冲池就是描述符池,在创建描述符池的时候需要分配描述符池的大小,里面各描述符集的数量。

描述符集就是描述符的集合,Set 就是 DataSet 就是数据集。

描述符用以关联用户的数据资源和着色器,如Uniform、Sampler、Texture等,为了让VulkanAPI识别资源,引入描述符和描述符集布局。描述符布局绑定就是定义绑定关系,如绑定到着色器layout(binding = 0)上。描述符是一种通信协议,用于CPU与着色器通信。在系统内部,描述符提供了一种静默的机制,通过位置绑定的方式来关联资源内存与着色器。

Vulkan渲染管线

共三种,ComputePipeline和GraphicsPipeline和Ray tracing pipeline。Pipeline中包含了PipelineLayout,PipelineLayout制定了管线使用的那些DescriptoSetLayout和PushConstant。

计算管线(Compute Pipeline)是比较简单的一种,因为它所支持的都是只计算的程序(称为Computer Shader)。

GraphicsPipeline要复杂得多如下图所示,因为它包含了所有的参数,如顶点、片段、几何、计算和曲面细分着色器(如果启用了该功能的话),再加上像顶点属性、输入装配、图元结构、光栅化参数、深度/模板测试参数、面剔除和Blend Const、视口(ViewPort)参数还有RenderPass等,Vulkan会将这些状态固定成一个大的、不可更改的对象。

显示评论