现代图形 API 的基石 Vulkan(一):绘制第一个三角形

从设置可以同时处理的帧数开始

1
2
3
4
// 我们希望让 GPU 和 CPU 保持忙碌。为了做到这一点,我们可以在上一个命令缓冲区仍在执行时就开始构建一个新的
// 此数字定义了可以同时处理的帧数
// 增加此数字可能会提高性能,但也会引入额外的延迟
#define MAX_CONCURRENT_FRAMES 2

我们可以将 CPU 和 GPU 的配合想象成一条流水线生产作业

为什么增加数字可以“提高性能”(提高吞吐量)?

这里的“性能”通常指的是 FPS(帧率/吞吐量)

在图形渲染中,CPU 和 GPU 是两个独立工作的处理器。

  • **如果设置 MAX_CONCURRENT_FRAMES = 1**:
    • CPU 必须先花时间录制完命令缓冲区。
    • GPU 接着渲染这一帧。
    • 在此期间,CPU 往往处于空闲等待状态(因为 GPU 没干完,CPU 不能提交下一帧)。
    • 反之,当 CPU 在录制新一帧时,GPU 也在空闲(如果上一帧还没开始渲染)。
    • 这种“一等一”的模式导致 CPU 和 GPU 无法同时满载,帧率上限受到限制。
  • 如果增加到 2 或更多
    • 你允许 CPU 在 GPU 渲染上一帧的同时,去“预录制”下一帧。
    • 当 GPU 渲染完 Frame N 时,CPU 已经准备好了 Frame N+1 的命令,GPU 可以无缝衔接继续工作。
    • 结果:CPU 和 GPU 都能保持高负载,GPU 几乎不会因为等待 CPU 录制命令而空转。这显著提高了平均 FPS。

为什么会引入“额外的延迟”(增加输入响应时间)?

这里的“延迟”指的是 Input Latency(输入延迟),即从你按下鼠标或键盘那一刻,到屏幕上看到画面变化的时间。

  • 缓冲队列的代价: 增加 MAX_CONCURRENT_FRAMES 本质上是增加了一个等待队列。如果你设置了 3 帧,这意味着当前 GPU 正在处理的一帧(Frame N),后面还排着 Frame N+1 和 Frame N+2。
  • 输入响应滞后: 如果你现在点击鼠标,你的输入指令只能被写入(或者是影响到)Frame N+3 的数据中。
    • 这意味着你的输入必须等待 Frame N, N+1, N+2 全部渲染完成并显示,才能看到结果。
    • 流水线越深,输入指令到达屏幕显示的时间就越长。

理解 CPU 必须先花时间录制完命令缓冲区,到底在录制什么?

在旧 API(如 OpenGL)中,你调用一个 glDrawArrays,驱动程序会一边替你翻译,一边在后台悄悄发送给 GPU。API 隐藏了录制的细节

但在 Vulkan 中,这一过程被显式化了:

  • **录制 (Recording)**:CPU 将你的图形逻辑(比如“我要画一个三角形”)翻译成 GPU 能够直接识别的二进制指令流。这个过程涉及到内存访问、状态校验,是非常消耗 CPU 时间的。
  • 非立即执行:当你调用 vkCmdDrawIndexed 时,什么都没画出来。你只是在内存里写下了一条指令。只有当这一大串指令通过 vkQueueSubmit 提交给 GPU 后,GPU 才会开始执行它们。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 开始录制
vkBeginCommandBuffer(commandBuffer, &cmdBufInfo);

// 2. 写入指令:开始渲染通道
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE);

// 3. 写入指令:设置视口和裁剪
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);

// 4. 写入指令:绑定资源(描述符集、流水线、缓冲区)
vkCmdBindDescriptorSets(commandBuffer, ...);
vkCmdBindPipeline(commandBuffer, ...);
vkCmdBindVertexBuffers(commandBuffer, ...);

// 5. 写入指令:这是真正的“绘制”指令
vkCmdDrawIndexed(commandBuffer, indices.count, 1, 0, 0, 0);

// 6. 结束渲染通道
vkCmdEndRenderPass(commandBuffer);

// 7. 结束录制
VK_CHECK_RESULT(vkEndCommandBuffer(commandBuffer));

CPU 必须完成这一连串繁琐的翻译和记录工作,才能让 GPU 开始渲染。

设置顶点缓冲区

顶点数据结构:组成部分

  • position[3]: 定义了点的 3D 坐标 (x, y, z)。这是为了告诉 GPU 这个点在空间中的位置。
  • color[3]: 定义了点的 RGB 颜色 (r, g, b)。这是为了告诉 GPU 这个点显示什么颜色。
1
2
3
4
5
// 三角形顶点布局数据结构
struct Vertex {
float position[3];
float color[3];
};

顶点缓冲区 (Vertex Buffer)

  • 角色:这是“原材料库”。它存的是点的坐标、颜色、法线等。
  • 管线作用:输入装配器(Input Assembler)从这里直接读取数据,然后一股脑儿送给顶点着色器(Vertex Shader)。
1
2
3
4
5
// 顶点缓冲区和属性
struct {
VkDeviceMemory memory{VK_NULL_HANDLE}; // 此缓冲区的设备内存句柄
VkBuffer buffer{VK_NULL_HANDLE}; // 内存所绑定的 Vulkan 缓冲区对象的句柄
} vertices;

在 Vulkan 中,这段代码体现了其最核心的设计哲学:显式控制(Explicit Control)

与 OpenGL 或 DirectX 等“自动挡” API 不同,Vulkan 不会将“资源定义”和“物理内存分配”混为一谈。理解这段代码,你需要区分两个概念:“逻辑上的房子”“物理上的地皮”

概念拆解:房子与地皮

你可以把这两个成员变量想象成房地产开发:

  • VkBuffer buffer(逻辑上的房子)

    • 它是一个句柄(Handle),告诉 GPU:“我是一个缓冲区,我要用来存顶点数据,我的大小是 1024 字节,我要被用作顶点输入。”
    • 注意:此时它仅仅是一个定义,它并没有真正的存储空间来存放数据。如果你现在尝试往里面写数据,程序会崩溃,因为它的地基是空的。
  • VkDeviceMemory memory(物理上的地皮)

    • 它是一个内存对象,代表了真正从 GPU 显存(VRAM)或者系统内存(RAM)中申请下来的一块“物理空间”。
    • 它有大小、属性(比如是允许 CPU 读写,还是仅 GPU 高速访问)。

为什么要分开?(为什么要这么麻烦?)

你可能会问:为什么要分得这么细,像 OpenGL 那样 glBufferData 一行代码搞定不好吗?

答案是:为了“精细化管理”和“高性能”。

  1. 内存池技术(Sub-allocation)
    在真实的商业游戏引擎中,向操作系统申请内存是一个非常昂贵的系统调用。如果我们为每一个 Buffer 都申请一块独立的内存,开销极大。

    • Vulkan 的做法:我们可以一次性申请一块巨大VkDeviceMemory(比如 256MB),然后将这块大空间切分成无数小块,分配给不同的 VkBufferVkImage
    • 这种设计允许我们将多个资源打包放在同一个内存块中,极大地减少了内存分配次数和显存碎片。
  2. 物理属性匹配
    有些内存适合 CPU 快速读写(Host Visible),有些适合 GPU 快速渲染(Device Local)。通过将 BufferMemory 分离,你可以创建一个 Buffer,然后根据需求选择最合适的“地皮”把它绑定上去。

如何产生联系?(vkBindBufferMemory

这代码定义的两个成员变量,只是“名义上的关联”。它们真正发生关系是在你调用以下函数时:

1
2
// 将上面申请的内存 "memory" 绑定到缓冲区 "buffer" 上
vkBindBufferMemory(device, vertices.buffer, vertices.memory, 0);

这一步操作就像是给“房子”分配了“地皮”。一旦执行了这行代码,buffer 才真正拥有了存储数据的物理基础。

总结

  • VkBuffer规范:定义了数据的用途和大小。
  • VkDeviceMemory资源:提供了数据的存放场所。
  • 这种分离赋予了开发者管理 GPU 显存的最大自由度,也是 Vulkan 高性能的基石。
1
2
3
4
5
6
// 索引缓冲区
struct {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
uint32_t count{0};
} indices;

索引缓冲区和顶点缓冲区的区别

在 Vulkan 的内存管理层面,它们几乎没有区别;但在渲染逻辑(管线)层面,它们的作用天差地别。

让我们从“结构”和“逻辑”两个维度来拆解一下。


结构层面:它们是“双胞胎”

struct 的定义来看,你会发现 indicesvertices 在 Vulkan 看来都是缓冲区(Buffer)

  • 完全相同点
    • 内存分配:两者都需要 VkDeviceMemory,都需要分配显存,都需要映射,都需要通过 vkBindBufferMemory 进行绑定。
    • 暂存(Staging)机制:在你的 createVertexBuffer 代码中,你用了同样的逻辑把数据从 CPU 拷贝到 GPU(Staging Buffer -> Device Local Buffer)。Vulkan 并不关心缓冲区里存的是“顶点数据”还是“索引数据”,它只负责搬运字节流。

所以,你可以认为它们在 Vulkan 的内存管理眼界里,仅仅是“两块存着不同数据的显存区域”。


逻辑层面:它们的“身份”完全不同

虽然它们都是显存,但在图形管线(Graphics Pipeline)中,它们扮演的角色完全不同:

A. 顶点缓冲区 (Vertex Buffer)

  • 角色:这是“原材料库”。它存的是点的坐标、颜色、法线等。
  • 管线作用:输入装配器(Input Assembler)从这里直接读取数据,然后一股脑儿送给顶点着色器(Vertex Shader)。

B. 索引缓冲区 (Index Buffer)

  • 角色:这是“指针索引表”。它存的是 uint32_t 类型的整数,这些整数指向顶点缓冲区中某一个点的下标。
  • 管线作用:它用来复用顶点。
    • 例子:如果你要画一个正方形,你需要 4 个顶点。如果你不使用索引(直接画三角形列表),你需要定义 6 个顶点(两个三角形 x 3 个顶点)。但如果你使用索引,你只需要定义 4 个顶点,然后通过索引告诉 GPU:“第一个三角形用 0, 1, 2 号点,第二个三角形用 2, 3, 0 号点”。
    • 这大大节省了显存,并降低了顶点着色器的计算压力。

程序中的关键差异

为什么在你的结构体里 indices 多了一个 uint32_t count,而 vertices 没有?

这是因为调用绘图指令时,它们被喂给了不同的函数:

  • 顶点缓冲区 是通过 vkCmdBindVertexBuffers 绑定的,它只管绑定在哪儿。
  • 索引缓冲区 是通过 vkCmdBindIndexBuffer 绑定的。
  • 绘图指令
    • 如果你用的是顶点缓冲区(非索引渲染):调用 vkCmdDraw,你需要告诉 GPU 你有多少个顶点。
    • 如果你用的是索引缓冲区(索引渲染):调用 vkCmdDrawIndexed,你需要告诉 GPU 你有多少个索引(这就是 indices.count 的用途)。

所以,你可以把 vertices 看作“仓库”(存东西),把 indices 看作“目录”(告诉你去仓库拿哪个东西)。在代码结构上它们是对称的,但在 GPU 运行时,它们一个在“被读”,一个在“引导阅读”。

缓冲区块 UBO:OpenGL vs. Vulkan

1
2
3
4
5
6
7
8
9
10
// Uniform 缓冲区块对象
struct UniformBuffer {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
// 描述符集存储了绑定到着色器中绑定点的资源
// 它将不同着色器的绑定点与用于这些绑定的缓冲区和图像连接起来
VkDescriptorSet descriptorSet{VK_NULL_HANDLE};
// 我们保留指向映射缓冲区的指针,以便可以通过 memcpy 轻松更新其内容
uint8_t *mapped{nullptr};
};

UniformBuffer 是 Vulkan 渲染中至关重要的数据结构,它负责处理那些在整个渲染过程中保持相对稳定,但每帧可能都会变化的数据,例如:投影矩阵(Projection Matrix)、视图矩阵(View Matrix)或模型矩阵(Model Matrix)。

可以把它理解为 “CPU 给 GPU 传递全局参数的窗口”

让我们拆解这个结构体的每一个成员,看看它们分别负责什么工作:

结构体成员详解

  • VkDeviceMemory memory & VkBuffer buffer

    • 上面已经陈述过。它们负责在显存中开辟一块空间,用来存放你的矩阵数据(ShaderData)。这块空间是“物理存储区”。
  • VkDescriptorSet descriptorSet (核心难点)

    • 这是什么? 把它想象成一个 “遥控器”或“引路人”
    • 为什么要它? 着色器(Shader)代码(如 GLSL)里写着 layout(set = 0, binding = 0) uniform UBO。着色器并不知道这个 UBO 到底对应哪一块显存。descriptorSet 的作用就是连接:它告诉 GPU,“当你运行到那个着色器代码时,请去使用这个特定的 buffer 里面的数据”。
    • 它是动态的:Vulkan 允许你通过更换不同的描述符集,在不同的渲染任务中指向不同的数据块。
  • uint8_t *mapped (性能优化)

    • 这是什么? 这是一个指向 CPU 内存的指针,它直接映射(Map)到了 GPU 的那一块内存上。
    • 它是怎么工作的? 在 Vulkan 中,通常更新数据需要一连串复杂的“拷贝-传输”命令。但对于 UniformBuffer,我们使用一种特殊内存(Host Visible & Host Coherent),允许 CPU 直接通过这个 mapped 指针进行 memcpy 操作。
    • 意义:这省去了 vkCmdCopyBuffer 等繁琐步骤,只要你在 CPU 上执行 memcpy,数据几乎实时就会出现在 GPU 可见的地方。这对于每一帧都要刷新的矩阵数据来说,是最高效的方式。

为什么需要这个复杂的结构?

在 OpenGL 中,你可能习惯了用 glUniformMatrix4fv 直接传参。但在 Vulkan 中,为了性能和多线程安全性,这种“即时传参”被彻底禁止了。

现在的逻辑链条是:

  1. 准备数据:CPU 计算好矩阵数据(projectionMatrix, modelMatrix 等)。
  2. 更新内存:CPU 通过 mapped 指针,直接把数据写到这块映射好的显存里(memcpy)。
  3. 绑定资源:通过 vkCmdBindDescriptorSets,把这块显存“喂”给 GPU 正在执行的渲染流水线。
  4. 着色器读取:GPU 执行渲染时,通过 descriptorSet 自动找到这块显存,读取数据进行顶点变换。

这个 UniformBuffer 结构体就是为了“快速且清晰地更新全局参数”而设计的。它利用 mapped 指针快速更新数据,利用 descriptorSet 告诉 GPU 数据在哪,从而实现了 CPU 到 GPU 高效的参数传递。

现在的代码里有 MAX_CONCURRENT_FRAMES(也就是双缓冲),这意味着实际上为每一帧都准备了一个独立的 UniformBuffer,这样 CPU 更新第 2 帧的矩阵时,绝对不会干扰到 GPU 正在渲染第 1 帧,这正是 Vulkan 并行渲染的高级之处。

对比 OpenGL (UBO) vs. Vulkan (UniformBuffer)

在 OpenGL 中,很多操作是“全局状态机”驱动的,而在 Vulkan 中,所有操作都是“对象绑定”驱动的。

特性 OpenGL (UBO) Vulkan (Uniform Buffer)
操作风格 基于全局状态 (State-based) 基于描述符对象 (Object-based)
绑定方式 调用 glBindBufferBase 将缓冲区绑定到全局“绑定点” 创建 DescriptorSet,显式关联 Buffer 和 Shader
代码量 少,几行代码搞定 多,需要创建 Pool, Layout, Set 等
线程安全性 很难在多线程中同时操作,容易引发状态冲突 原生支持,不同线程可以录制不同的描述符集

OpenGL 的做法(“简单模式”)

在 OpenGL 中,如果你想用 Uniform Buffer,流程大致是:

  1. 创建glGenBuffers
  2. 绑定glBindBuffer(GL_UNIFORM_BUFFER, buffer)
  3. 连接glBindBufferBase(GL_UNIFORM_BUFFER, binding_point, buffer)
  4. 着色器使用:在 GLSL 里写 layout(std140, binding = X) uniform ...

你会发现,OpenGL 只需要你把 Buffer 扔到某个“槽位”(Binding Point)上,着色器自动就能找到它。这很方便,但也意味着所有着色器都要争抢这几个有限的槽位,且很难管理。

Vulkan 的做法(“精细控制模式”)

Vulkan 抛弃了“槽位”的自动绑定,改用了**描述符集 (Descriptor Set)**:

  • 它不是“绑定到槽位”,而是“定义接口”:在 Vulkan 中,你必须预先定义 DescriptorSetLayout(蓝图),告诉 GPU 这个着色器需要什么样的资源。
  • 它不是“全局状态”,而是“局部引用”:你可以给每一个物体(或者每一帧)都分配一个独立的 DescriptorSet

为什么要这么折腾?

  • 为了多线程渲染:在 OpenGL 里,修改全局状态(Bind 某个 Buffer)会导致整个渲染管线“暂停”或产生依赖。而在 Vulkan 中,因为你明确地把 Buffer 包裹在 DescriptorSet 里,CPU 可以并行在多个线程里录制命令缓冲区,互不干扰。
  • 驱动优化:GPU 驱动程序在 Vulkan 中通过这种显式的描述符引用,能提前知道哪些资源会被用到,从而做更激进的优化。
1
2
// 我们每帧使用一个 UBO,这样我们就可以进行帧重叠,并确保 Uniform 在仍在使用时不会被更新
std::array<UniformBuffer, MAX_CONCURRENT_FRAMES> uniformBuffers;

开头已经说过 MAX_CONCURRENT_FRAMES 设置同时处理的帧数的意义。

二进制桥梁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 为了简单起见,我们使用与着色器中相同的 uniform 块布局:
//
// layout(set = 0, binding = 0) uniform UBO
// {
// mat4 projectionMatrix;
// mat4 modelMatrix;
// mat4 viewMatrix;
// } ubo;
//
// 这样我们就可以直接将 ubo 数据 memcopy 到 ubo 中
// 注意:应该使用与 GPU 对齐的数据类型,以避免手动填充 (vec4, mat4)
struct ShaderData {
glm::mat4 projectionMatrix;
glm::mat4 modelMatrix;
glm::mat4 viewMatrix;
};

memcpy(mapped, &shaderData, sizeof(ShaderData))

  • 它的含义:CPU 不去一个个变量地修改数据,而是直接把这块内存的二进制内容“暴力”地复制到显存中。
  • 前提条件:这个前提非常苛刻。CPU 结构体中第一个成员(projectionMatrix)的起始地址、每一个矩阵的大小、以及矩阵之间的间隔,必须和 GPU 着色器内部期待的格式完全一致。如果有一丁点儿偏差(比如编译器在 C++ 里加了填充字节,而 GPU 里没有),你渲染出来的画面就会因为读取了错误的内存偏移而扭曲、甚至崩溃。

在 Vulkan 开发中,这被称为“二进制对齐映射(Binary Layout Mapping)”。

简单来说,这段代码的目标是:让 CPU 的内存布局(C++ 结构体)和 GPU 的内存布局(GLSL 着色器)完全对齐,从而实现“零拷贝”的快速更新。

VkPipelineLayout 共享流水线布局

1
2
3
4
// 流水线布局被流水线用于访问描述符集
// 它定义了流水线所使用的着色器阶段与着色器资源之间的接口(不绑定任何实际数据)
// 只要接口匹配,流水线布局就可以在多个流水线之间共享
VkPipelineLayout pipelineLayout{VK_NULL_HANDLE};

VkPipelineLayout 之所以能够“共享”,是因为 Vulkan 将“接口定义”“实现逻辑”中剥离了出来,变成了显式的对象。

我们可以通过一个通俗的类比,并对比 OpenGL 和 Vulkan 的工作流,来理解为什么这一行代码揭示了两者最本质的设计差异。


核心对比:什么是“共享”?

在图形编程中,我们关注的是“管线(Shader)”如何获取“数据(Buffer/Texture)”。

OpenGL 的做法:隐式约定 (Implicit)

在 OpenGL 中,并不存在一个类似 PipelineLayout对象

  • 做法:你直接在着色器里写 layout(binding = 0)。在 CPU 端,你通过 glBindBuffer 或者 glUniform 把数据推送到插槽 0 上。
  • 共享机制:OpenGL 没有“共享接口”的概念。如果你有两个着色器,它们都用到同一个 UBO,你必须在 CPU 代码中人为地保证这两个着色器都去查询“插槽 0”。如果其中一个着色器改了绑定点,你必须手动去修改 CPU 代码。
  • 本质接口是“隐式”的,依赖于程序员的记忆和编码规范。

Vulkan 的做法:显式契约 (Explicit Contract)

在 Vulkan 中,VkPipelineLayout 是一个独立的对象

  • 做法:你先定义一个“布局对象”(蓝图),然后将这个对象绑定到不同的流水线(Pipeline)。
  • 共享机制:由于 PipelineLayout 是一个对象,你完全可以创建一个 GlobalDataLayout,然后把它传给“渲染地形的流水线”、“渲染角色的流水线”和“渲染UI的流水线”。它们都强制遵守同一个契约。
  • 本质接口是“显式”的,变成了一个可以存储、传递和复用的资源。

类比理解:电源插座 vs. 定制接口

  • OpenGL 的“电源排插”模式: 你有一个排插(OpenGL 上下文),上面有 8 个插孔(Binding Points)。不管你是电饭煲(着色器 A)还是吹风机(着色器 B),你都必须自己找个插孔插上去。如果你不小心把电饭煲插到了吹风机的插孔里,或者忘了插,程序就会出错。没有一个明确的对象来规定“谁必须插在哪”。
  • Vulkan 的“定制化接头”模式: 你设计了一个专门的接口(VkPipelineLayout),规定了必须有 3 根针脚(对应的 Buffer)。任何想要工作的设备(Pipeline),必须先证明它支持这个接口,并且通过这个接口接入电源。只要接口形状一致(Layout 匹配),你可以有 100 个设备复用同一个接口标准,它们甚至不需要关心对方是谁。

为什么“可以共享”如此重要?(性能与架构)

你代码里的这行注释,背后其实隐藏着 Vulkan 的两大核心优势:

A. 驱动性能优化(预编译)

在 OpenGL 中,因为没有显式的接口布局,驱动程序必须在每次你调用 glDraw 时,去校验:“当前着色器和当前绑定的 Buffer 匹配吗?”。这是一种每帧都在重复的运行时校验,非常浪费 CPU。

而在 Vulkan 中,因为你有 VkPipelineLayout

  • 驱动在 Pipeline 创建时(预编译) 就完成了所有校验。
  • 在渲染循环里,GPU 只需要确认:当前的 DescriptorSet 是否符合那个已知的 PipelineLayout。这是一个极其快速的地址核对操作,零校验开销

B. 多线程渲染的安全性

如果接口是“隐式”的(OpenGL),在多线程下修改全局绑定插槽,极易引发竞态条件,导致渲染崩溃。

因为 Vulkan 使用了 PipelineLayoutDescriptorSet,资源与接口被绑定在了一起,多线程可以并行地录制不同的命令缓冲区,因为每个线程都知道它们引用的资源布局(PipelineLayout)是确定的、不变的。

1
2
3
4
5
// 流水线(通常称为“流水线状态对象”)用于烘焙影响流水线的所有状态
// 虽然在 OpenGL 中几乎可以随时更改每个状态,但 Vulkan 要求预先布置图形(和计算)流水线状态
// 因此,对于每种非动态流水线状态组合,都需要一个新的流水线(有一些例外这里不讨论)
// 尽管这增加了一个提前规划的新维度,但对于驱动程序的性能优化来说,这是一个很好的机会
VkPipeline pipeline{VK_NULL_HANDLE};

怎么理解对于每种非动态流水线状态组合,都需要一个新的流水线?

这是一个非常硬核且核心的概念。在 Vulkan 中,这被称为 “流水线状态烘焙(Pipeline State Baking)”

为了让你彻底明白,我们可以把 OpenGL 的模式比作“厨师现场烹饪”,而把 Vulkan 的模式比作“工厂流水线生产”。

厨师与流水线的逻辑差异

OpenGL 的模式:厨师现场烹饪 (Runtime Validation)

在 OpenGL 中,当你调用 glDrawArrays 时,你可以随时修改状态(比如 glEnable(GL_DEPTH_TEST)glPolygonMode)。

  • 代价:对于 GPU 驱动程序来说,这就像是厨师在炒菜炒到一半,你突然让他换个锅、换个铲子。厨师必须停下来,检查此时所有的参数,重新校验这些参数是否合法,然后才能继续炒菜。
  • 性能损耗:这个“校验”过程非常昂贵,而且是发生在每一帧的渲染循环里的,极大地拖累了 CPU 的速度。

Vulkan 的模式:工厂流水线 (Pre-baked Pipelines)

在 Vulkan 中,流水线(Pipeline)就像是一个已经搭建好并调试完毕的专用生产线

  • 烘焙(Baking):在程序初始化时,你把所有状态(深度测试开关、混合模式、着色器等)一股脑儿塞进去,Vulkan 驱动程序会花费大量时间,将其编译成 GPU 最能直接执行的、高度优化的“机器码”。
  • 执行:一旦生成了 VkPipeline,它就是不可变的。在渲染循环里,你只需要切换 vkCmdBindPipeline,GPU 瞬间就能切入该状态,中间不需要任何“校验”和“翻译”。

为什么“每种状态组合”都需要一个新的流水线?

因为 “每种状态组合对应的机器码是不同的”

假设你的程序有两个功能:

  1. 场景渲染:开启深度测试,关闭混合。
  2. UI 渲染:关闭深度测试,开启混合。

在 Vulkan 看来,这两者的底层指令是完全不同的:

  • 对于场景,GPU 需要插入深度比较逻辑。
  • 对于 UI,GPU 需要插入颜色混合公式。

如果你想切换到 UI 渲染,你不能只修改场景流水线的一个“开关”,因为那样做会导致 GPU 内部逻辑出错。你必须调用 vkCmdBindPipeline,换上一条专门为“关闭深度测试+开启混合”而预编译好的生产线(流水线对象)。

这就是为什么它是“组合”的原因:
任何一个微小的状态改变(比如 depthCompareOpLESS 变成 LESS_OR_EQUAL),对于驱动程序来说,都可能意味着要重新生成一份 GPU 指令序列。

这带来了什么好处?

这种“看似繁琐”的设计,其实是现代图形 API 性能的基石:

  1. 零运行时开销:当调用 vkCmdDraw 时,GPU 已经完全知道当前状态的所有细节,不需要在绘制瞬间去问 CPU:“现在深度测试开没开?”
  2. 驱动程序解放:驱动程序不需要再像 OpenGL 那样去猜测你的意图,它在创建 Pipeline 时就已经把所有事情算好了。
  3. 多线程并行:因为 Pipeline 是不可变的(Immutable),所以多个线程可以安全地使用同一个 Pipeline 对象,而不会出现冲突。

什么是例外?(动态状态)

你代码里有一行很关键的注释:“(有一些例外这里不讨论)”

Vulkan 也意识到如果所有状态都必须预先“烘焙”,那确实会造成流水线对象爆炸( Pipeline State Object Explosion)。因此,Vulkan 允许一部分状态设置为 “动态的(Dynamic State)”

例如:

  • 视口大小 (Viewport) 和 **裁剪区域 (Scissor)**。

这两个状态在渲染过程中经常改变,但对 GPU 底层指令影响较小。因此,你可以在创建流水线时将其标记为 VK_DYNAMIC_STATE_VIEWPORT。这样,即使流水线没变,你也可以在录制命令缓冲区时,通过 vkCmdSetViewport 实时修改它们,而不需要创建新的流水线。

总结

理解这一点,你需要接受一个思维转变:

  • OpenGL:状态是“全局变量”,可以随时改,驱动帮你收拾烂摊子。
  • Vulkan:流水线是“静态蓝图”,必须提前规划好,驱动帮你预编译成最优指令。

为了性能,我们把“运行时开销”转移到了“初始化开销”上。

VkDescriptorSetLayout 描述符集布局

1
2
3
// 描述符集布局描述了着色器绑定布局(而不实际引用描述符)
// 像流水线布局一样,它基本上是一个蓝图,只要它们的布局匹配,就可以与不同的描述符集一起使用
VkDescriptorSetLayout descriptorSetLayout{VK_NULL_HANDLE};

OpenGL vs Vulkan

核心架构差异:显式控制 vs. 隐式自动化

在图形编程中,GPU 需要知道 Shader 如何与数据(Buffer/Texture)对接。

Vulkan:显式控制(Explicit Contract)

Vulkan 采取的是“先定义,后使用”的严谨模式。

  • 设计哲学:所有资源接口(Layout)必须在渲染循环开始前定义并烘焙。
  • **蓝图机制 (VkDescriptorSetLayout)**:
    • 定义蓝图VkDescriptorSetLayout 充当了“静态蓝图”的角色。它不绑定具体内存,只定义接口规格(例如:“槽位0必须是 UBO,槽位1必须是 Texture”)。
    • 数据解耦:这种设计将“接口定义”“数据实例”完全分离。Layout 是蓝图(静态),Descriptor Set 是填入具体 Buffer 句柄的实例(动态)。
  • 性能优势:驱动程序在创建 Pipeline 时就已完成所有资源布局的验证。在 DrawCall 时,GPU 没有任何运行时校验负担,这消除了驱动程序的“猜测”和“运行时补偿”开销。

OpenGL:隐式自动化(Implicit Automation)

OpenGL 采取的是“你只管用,驱动来猜”的自动化模式。

  • 设计哲学:尽可能减少开发者负担,将资源映射的任务交给驱动程序。
  • **自动映射 (glLinkProgram)**:
    • 当你调用 glLinkProgram 时,OpenGL 驱动会自动扫描你的 Shader 代码。它在幕后解析 layout(binding=...),并为你生成一套隐式的资源布局映射。
  • 性能代价:这种自动化是“运行时”发生的。每当你进行 DrawCall 时,驱动程序都需要检查当前的绑定状态是否符合 Shader 的预期。这种“运行时查询”带来了巨大的 CPU 开销。

关键对比总结表

维度 Vulkan (VkDescriptorSetLayout) OpenGL (隐式布局)
布局定义时机 初始化阶段(预编译) 链接阶段glLinkProgram 时)
验证时机 预校验(Pipeline 创建时) 运行时(Draw Call 时)
设计核心 资源接口显式化,提前优化 驱动程序自动匹配,降低开发难度
驱动负载 极低(只管执行指令) 较高(需在渲染时执行状态校验)
灵活性 高(可实现多线程并行录制) 中(受限于全局状态机)

理解“为什么 Vulkan 要这么麻烦?”

你可以将上述差异理解为“制造工艺”的差异:

  • OpenGL 像是“现场拼装”
    工人在施工现场(渲染循环)看到一个零件(Buffer),赶紧对照图纸(Shader 绑定点)看看能不能装上。如果装不上,工人还要报错。虽然灵活性高,但现场拼装极慢,且容易出错。

  • Vulkan 像是“模块化工厂”
    你必须在开工前(Pipeline 创建阶段)把蓝图(Layout)画好,确认所有接口都对得上。等真正开工时,所有组件(Descriptor Set)都已经按规格装配好了。工人(GPU)拿到直接插上就能跑,无需校验,无需等待

VkDescriptorSetLayout 共享

如果你的多个 Shader(比如“基础着色器”、“法线贴图着色器”、“阴影着色器”)都使用相同的资源布局(比如大家都在 binding 0 绑定 UBO,在 binding 1 绑定纹理),你完全不需要为每个 Shader 创建不同的 Layout。

共享的好处

  1. 减少内存开销:不用创建几十个重复的 Layout 对象。
  2. 流水线重用VkPipelineLayout 可以包含多个 VkDescriptorSetLayout。如果多个流水线共用同一个资源布局标准,它们就可以共用同一个 PipelineLayout,这在切换流水线时能省下大量的驱动验证开销。

同步原语:渲染流水线上的交通信号灯

1
2
3
4
5
6
7
8
9
10
// 同步原语
// 同步是 Vulkan 的一个重要概念,OpenGL 几乎将其隐藏了。正确处理这一点对于使用 Vulkan 至关重要。

// 信号量用于协调图形队列内的操作并确保正确的命令顺序
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> presentCompleteSemaphores{};
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> renderCompleteSemaphores{};

VkCommandPool commandPool{VK_NULL_HANDLE};
std::array<VkCommandBuffer, MAX_CONCURRENT_FRAMES> commandBuffers{};
std::array<VkFence, MAX_CONCURRENT_FRAMES> waitFences{};

理解同步原语(Synchronization Primitives)是学习 Vulkan 最具挑战性的一步,也是它与 OpenGL 最本质的区别。

简单来说:在 OpenGL 中,驱动程序帮你“隐式”处理了所有等待;而在 Vulkan 中,你必须通过这些“原语”显式地告诉 GPU:“这儿得等一下”、“那儿可以开始了”。

我们可以把它们看作是渲染流水线上的“交通信号灯”


VkFence (栅栏):CPU 与 GPU 的对话

定义VkFence 是用于主机 (CPU) 与设备 (GPU) 之间的同步。

  • 比喻:你是一个项目经理(CPU),GPU 是你的团队。你派团队去完成一个复杂的绘图任务(命令缓冲区)。当你问团队“活儿干完了吗?”时,你不能一直盯着他们看,你可以放一个“栅栏”在门口。如果团队还没干完,栅栏是关着的;团队干完了,会把栅栏打开。
  • 在代码中
    1
    vkWaitForFences(device, 1, &waitFences[currentFrame], VK_TRUE, UINT64_MAX);
    这里 CPU 在“等待栅栏”。如果 GPU 还没有处理完这一帧(比如还没画完上一帧的三角形),CPU 就会在这里“阻塞”住。这是为了确保我们在重复使用同一个命令缓冲区(Command Buffer)之前,它确实已经处于空闲状态了。

VkSemaphore (信号量):GPU 与 GPU 的对话

定义VkSemaphore 是用于图形队列内(GPU 操作之间)的同步。

  • 比喻:这就像是接力赛。厨师 A(图像获取)把盘子交给厨师 B(渲染操作),厨师 B 又把盘子交给厨师 C(显示操作)。
  • 代码中的两类信号量
    • **presentCompleteSemaphores**:告诉渲染队列:“显卡已经拿到要画的图像了,你可以开始渲染了。”(防止在图像还没准备好时就往上面画画)。
    • **renderCompleteSemaphores**:告诉显示系统:“我已经画好了,你可以把这张图扔到显示器上去了。”(防止在显卡还没画完时就把半成品呈现在屏幕上)。
  • 特点:它们完全是在 GPU 内部完成的,不需要 CPU 介入,也不会导致 CPU 阻塞,速度极快。

为什么需要 std::array (帧重叠策略)

你可能会问,为什么 VkFenceVkSemaphore 都要定义成 std::array,而且大小是 MAX_CONCURRENT_FRAMES

这就是我们在处理“**帧在飞行中 (Frames in Flight)**”的问题。

  • 没有数组时:如果只有一套同步对象,CPU 必须等 GPU 把第 0 帧画完,才能去录制第 1 帧。CPU 和 GPU 永远只能有一个在工作。
  • **有了数组 (MAX_CONCURRENT_FRAMES = 2)**:
    • 我们有两套同步逻辑(Fence A/Semaphore A 和 Fence B/Semaphore B)。
    • 当 GPU 正在处理第 0 帧(使用第一套同步逻辑)时,CPU 可以立即跳过“等待”,去录制第 1 帧(使用第二套同步逻辑)。
    • 这样 CPU 和 GPU 就实现了并行工作

总结:OpenGL 为什么不需要这个?

在 OpenGL 中,当你调用 glDrawArrays 时,OpenGL 驱动会在内部帮你做这些事:

  1. 如果上一帧还没渲染完,驱动会直接让你的 CPU “卡死”在 draw call 里等待。
  2. 驱动帮你管理了所有图像的交换和内存状态。

OpenGL 的代价是“透明的性能损失”:因为驱动为了保证不出错,往往采取极其保守的同步策略(强行让 CPU 等 GPU)。

Vulkan 的策略是“权限与责任并存”:它给了你手动控制这些“红绿灯”的权力。虽然代码变复杂了,但你现在可以精准地安排 CPU 和 GPU 谁该等、谁该动,从而榨干硬件的并行性能。

一句话总结:

  • Fence:CPU 问 GPU:“干完活了吗?(同步到主机)”
  • Semaphore:GPU 内部步骤之间说:“我准备好了,你开始吧。(同步在设备)”

选择同步对象,跟踪当前帧

1
2
// 为了选择正确的同步对象,我们需要跟踪当前帧
uint32_t currentFrame{0};

理解 currentFrame 变量是掌握 Vulkan “帧重叠(Frames in Flight)” 架构的最后一块拼图。

简单来说,**currentFrame 就是一个“指针”或“索引”**,它指向你当前正在处理的那一套资源。

核心逻辑:它是如何运作的?

因为你定义了 MAX_CONCURRENT_FRAMES = 2,你实际上在内存中为“两套”渲染资源各准备了一份拷贝。你可以把它们想象成两条生产线:

  • 第 0 号生产线:包含 commandBuffers[0], waitFences[0], uniformBuffers[0]
  • 第 1 号生产线:包含 commandBuffers[1], waitFences[1], uniformBuffers[1]

currentFrame 的作用就是:告诉 CPU 和 GPU,现在轮到哪条生产线工作了。

在代码中它是如何“跑”起来的?

render() 函数末尾,一行关键代码:

1
currentFrame = (currentFrame + 1) % MAX_CONCURRENT_FRAMES;

这就是一个经典的 “循环队列(Round Robin)” 逻辑:

  1. 初始时 currentFrame = 0
  2. 渲染完第 0 帧,代码执行 (0 + 1) % 2,结果是 1
  3. 下一次渲染,它就会去使用 [1] 号下标的所有资源。
  4. 渲染完第 1 帧,代码执行 (1 + 1) % 2,结果是 0
  5. 回到开头,重新使用 [0] 号资源。

通过这种方式,CPU 永远不会在渲染第 1 帧时,去覆盖掉 GPU 正在使用的第 0 帧的缓冲区数据。

一个非常容易混淆的点:currentFrame vs imageIndex

在阅读 Vulkan 代码时,很多初学者会把 currentFrameimageIndex 弄混,请务必区分它们:

  • currentFrame (逻辑帧索引):这是你自己定义的,用于管理你的 CPU 资源(同步原语、Uniform 缓冲区)。你是在控制“CPU 正在给哪一套同步对象打信号”。
  • **imageIndex (图像索引)**:这是 Vulkan 交换链 (Swapchain) 返回的。它是 GPU 告诉你的:“嘿,现在屏幕显示器准备好接收第 X 号图像数据了。”

总结它们的互动:
你用 currentFrame 锁定了当前的同步原语(Fence/Semaphore),然后用 imageIndex 锁定了当前的帧缓冲区(Framebuffer)。渲染时,这两者共同协作,将你的画面绘制到正确的图像上。

为什么要理解它?

如果你把 currentFrame 的更新逻辑删掉,或者写错,会发生什么?

  • 同步冲突:CPU 可能会在 GPU 还没用完资源时,就强行修改它。
  • 画面撕裂或崩溃:GPU 读取了 CPU 正在修改的数据,导致渲染结果出现闪烁或直接触发 Vulkan 验证层的错误。

currentFrame 就是你的“节奏器”。它确保了 CPU 和 GPU 能够在两条(或多条)并行轨道上交替工作,既不发生碰撞,又能满负荷运转。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VulkanExample() : VulkanExampleBase() { 
// 构造函数:初始化当前类,并自动调用父类 VulkanExampleBase 的构造函数来准备 Vulkan 基础环境
title = "Vulkan Example - Basic indexed triangle";
// 设置操作系统窗口标题,方便在窗口管理器中识别此程序
settings.overlay = false;
// 禁用 UI 覆盖层 (ImGui)。在 Vulkan 中,渲染 UI 需引入额外的渲染通道 (Render Pass)、
// 流水线状态 (Pipeline State) 及描述符集 (Descriptor Sets)。关闭它可剥离辅助逻辑,
// 确保示例保留最纯粹的 Vulkan 核心架构 (如顶点着色与管线同步),方便学习与调试。
camera.type = Camera::CameraType::lookat;
// 指定相机控制模式为 'Look-at',通过位置(Position)和旋转(Rotation)来计算视图矩阵(View Matrix)
camera.setPosition(glm::vec3(0.0f, 0.0f, -2.5f));
// 设置摄像机位置:在世界空间 Z 轴后移 2.5 单位,确保三角形位于相机的视锥体前方
camera.setRotation(glm::vec3(0.0f));
// 设置摄像机旋转角度:初始化为 0,即摄像机默认朝向 Z 轴正方向
camera.setPerspective(60.0f, (float) width / (float) height, 1.0f, 256.0f);
// 配置透视投影矩阵:60度视场角(FOV)、使用当前窗口的宽高比、近裁剪面 1.0、远裁剪面 256.0
} // 此构造函数结束后,所有摄像机参数已准备好,随后将在 render() 中被上传到 UniformBuffer

内存异常判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 此函数用于请求支持我们请求的所有属性标志的设备内存类型(例如设备本地、主机可见)
// 如果成功,它将返回符合我们请求的内存属性的内存类型索引
// 这是必要的,因为实现可以提供任意数量具有不同内存属性的内存类型
// 您可以查看 https://vulkan.gpuinfo.org/ 了解不同内存配置的详细信息
uint32_t getMemoryTypeIndex(uint32_t typeBits, VkMemoryPropertyFlags properties) {
// 遍历此示例中使用的设备可用的所有内存类型
for (uint32_t i = 0; i < deviceMemoryProperties.memoryTypeCount; i++) {
if ((typeBits & 1) == 1) {
if ((deviceMemoryProperties.memoryTypes[i].propertyFlags & properties) ==
properties) {
return i;
}
}
typeBits >>= 1;
}

throw "Could not find a suitable memory type!";
}

硬件能力校验

它并不是在检查代码写错了没有,而是在检查:你要求的内存规格(Properties),这块 GPU 硬件是否真的给得起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
/*
* Vulkan Example - Basic indexed triangle rendering
*
* 注意:
* 这是一个“直面底层”的示例,旨在展示如何让 Vulkan 运行并显示内容
* 与其它示例不同,此示例不会使用辅助函数或初始化器
* (除了少数情况,例如交换链设置)
*
* Copyright (C) 2016-2024 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <fstream>
#include <vector>
#include <exception>

#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

#include <vulkan/vulkan.h>
#include "vulkanexamplebase.h"

// 我们希望让 GPU 和 CPU 保持忙碌。为了做到这一点,我们可以在上一个命令缓冲区仍在执行时就开始构建一个新的
// 此数字定义了可以同时处理的帧数
// 增加此数字可能会提高性能,但也会引入额外的延迟
#define MAX_CONCURRENT_FRAMES 2

class VulkanExample : public VulkanExampleBase {
public:
// 三角形顶点布局数据结构
struct Vertex {
float position[3];
float color[3];
};

// 顶点缓冲区和属性
struct {
VkDeviceMemory memory{VK_NULL_HANDLE}; // 此缓冲区的设备内存句柄
VkBuffer buffer{VK_NULL_HANDLE}; // 内存所绑定的 Vulkan 缓冲区对象的句柄
} vertices;

// 索引缓冲区
struct {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
uint32_t count{0};
} indices;

// Uniform 缓冲区块对象
struct UniformBuffer {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
// 描述符集存储了绑定到着色器中绑定点的资源
// 它将不同着色器的绑定点与用于这些绑定的缓冲区和图像连接起来
VkDescriptorSet descriptorSet{VK_NULL_HANDLE};
// 我们保留指向映射缓冲区的指针,以便可以通过 memcpy 轻松更新其内容
uint8_t *mapped{nullptr};
};
// 我们每帧使用一个 UBO,这样我们就可以进行帧重叠,并确保 Uniform 在仍在使用时不会被更新
std::array<UniformBuffer, MAX_CONCURRENT_FRAMES> uniformBuffers;

// 为了简单起见,我们使用与着色器中相同的 uniform 块布局:
//
// layout(set = 0, binding = 0) uniform UBO
// {
// mat4 projectionMatrix;
// mat4 modelMatrix;
// mat4 viewMatrix;
// } ubo;
//
// 这样我们就可以直接将 ubo 数据 memcopy 到 ubo 中
// 注意:应该使用与 GPU 对齐的数据类型,以避免手动填充 (vec4, mat4)
struct ShaderData {
glm::mat4 projectionMatrix;
glm::mat4 modelMatrix;
glm::mat4 viewMatrix;
};

// 流水线布局被流水线用于访问描述符集
// 它定义了流水线所使用的着色器阶段与着色器资源之间的接口(不绑定任何实际数据)
// 只要接口匹配,流水线布局就可以在多个流水线之间共享
VkPipelineLayout pipelineLayout{VK_NULL_HANDLE};

// 流水线(通常称为“流水线状态对象”)用于烘焙影响流水线的所有状态
// 虽然在 OpenGL 中几乎可以随时更改每个状态,但 Vulkan 要求预先布置图形(和计算)流水线状态
// 因此,对于每种非动态流水线状态组合,都需要一个新的流水线(有一些例外这里不讨论)
// 尽管这增加了一个提前规划的新维度,但对于驱动程序的性能优化来说,这是一个很好的机会
VkPipeline pipeline{VK_NULL_HANDLE};

// 描述符集布局描述了着色器绑定布局(而不实际引用描述符)
// 像流水线布局一样,它基本上是一个蓝图,只要它们的布局匹配,就可以与不同的描述符集一起使用
VkDescriptorSetLayout descriptorSetLayout{VK_NULL_HANDLE};

// 同步原语
// 同步是 Vulkan 的一个重要概念,OpenGL 几乎将其隐藏了。正确处理这一点对于使用 Vulkan 至关重要。

// 信号量用于协调图形队列内的操作并确保正确的命令顺序
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> presentCompleteSemaphores{};
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> renderCompleteSemaphores{};

VkCommandPool commandPool{VK_NULL_HANDLE};
std::array<VkCommandBuffer, MAX_CONCURRENT_FRAMES> commandBuffers{};
std::array<VkFence, MAX_CONCURRENT_FRAMES> waitFences{};

// 为了选择正确的同步对象,我们需要跟踪当前帧
uint32_t currentFrame{0};

VulkanExample() : VulkanExampleBase() {
// 构造函数:初始化当前类,并自动调用父类 VulkanExampleBase 的构造函数来准备 Vulkan 基础环境
title = "Vulkan Example - Basic indexed triangle";
// 设置操作系统窗口标题,方便在窗口管理器中识别此程序
settings.overlay = false;
// 禁用 UI 覆盖层 (ImGui)。在 Vulkan 中,渲染 UI 需引入额外的渲染通道 (Render Pass)、
// 流水线状态 (Pipeline State) 及描述符集 (Descriptor Sets)。关闭它可剥离辅助逻辑,
// 确保示例保留最纯粹的 Vulkan 核心架构 (如顶点着色与管线同步),方便学习与调试。
camera.type = Camera::CameraType::lookat;
// 指定相机控制模式为 'Look-at',通过位置(Position)和旋转(Rotation)来计算视图矩阵(View Matrix)
camera.setPosition(glm::vec3(0.0f, 0.0f, -2.5f));
// 设置摄像机位置:在世界空间 Z 轴后移 2.5 单位,确保三角形位于相机的视锥体前方
camera.setRotation(glm::vec3(0.0f));
// 设置摄像机旋转角度:初始化为 0,即摄像机默认朝向 Z 轴正方向
camera.setPerspective(60.0f, (float) width / (float) height, 1.0f, 256.0f);
// 配置透视投影矩阵:60度视场角(FOV)、使用当前窗口的宽高比、近裁剪面 1.0、远裁剪面 256.0
} // 此构造函数结束后,所有摄像机参数已准备好,随后将在 render() 中被上传到 UniformBuffer

~VulkanExample() {
// 清理使用的 Vulkan 资源
// 注意:继承的析构函数会清理存储在基类中的资源
vkDestroyPipeline(device, pipeline, nullptr);

vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);

vkDestroyBuffer(device, vertices.buffer, nullptr);
vkFreeMemory(device, vertices.memory, nullptr);

vkDestroyBuffer(device, indices.buffer, nullptr);
vkFreeMemory(device, indices.memory, nullptr);

vkDestroyCommandPool(device, commandPool, nullptr);

for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
vkDestroyFence(device, waitFences[i], nullptr);
vkDestroySemaphore(device, presentCompleteSemaphores[i], nullptr);
vkDestroySemaphore(device, renderCompleteSemaphores[i], nullptr);
vkDestroyBuffer(device, uniformBuffers[i].buffer, nullptr);
vkFreeMemory(device, uniformBuffers[i].memory, nullptr);
}
}

// 此函数用于请求支持我们请求的所有属性标志的设备内存类型(例如设备本地、主机可见)
// 如果成功,它将返回符合我们请求的内存属性的内存类型索引
// 这是必要的,因为实现可以提供任意数量具有不同内存属性的内存类型
// 您可以查看 https://vulkan.gpuinfo.org/ 了解不同内存配置的详细信息
uint32_t getMemoryTypeIndex(uint32_t typeBits, VkMemoryPropertyFlags properties) {
// 遍历此示例中使用的设备可用的所有内存类型
for (uint32_t i = 0; i < deviceMemoryProperties.memoryTypeCount; i++) {
if ((typeBits & 1) == 1) {
if ((deviceMemoryProperties.memoryTypes[i].propertyFlags & properties) ==
properties) {
return i;
}
}
typeBits >>= 1;
}

throw "Could not find a suitable memory type!";
}

// 创建此示例中使用的每帧(运行中)Vulkan 同步原语
void createSynchronizationPrimitives() {
// 信号量用于队列内的正确命令排序
VkSemaphoreCreateInfo semaphoreCI{};
semaphoreCI.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

// 栅栏用于在主机上检查绘制命令缓冲区的完成情况
VkFenceCreateInfo fenceCI{};
fenceCI.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
// 创建处于信号状态的栅栏(这样我们就不会在每个命令缓冲区的第一次渲染时等待)
fenceCI.flags = VK_FENCE_CREATE_SIGNALED_BIT;

for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
// 信号量用于确保在再次开始提交之前图像呈现完成
VK_CHECK_RESULT(vkCreateSemaphore(device, &semaphoreCI, nullptr,
&presentCompleteSemaphores[i]));
// 信号量用于确保在将图像提交到队列之前,所有提交的命令都已完成
VK_CHECK_RESULT(
vkCreateSemaphore(device, &semaphoreCI, nullptr, &renderCompleteSemaphores[i]));

// 栅栏用于确保命令缓冲区在再次使用之前已完成执行
VK_CHECK_RESULT(vkCreateFence(device, &fenceCI, nullptr, &waitFences[i]));
}
}

void createCommandBuffers() {
// 1. 创建命令池 (Command Pool)
// 命令池是命令缓冲区的“内存管理器”。
VkCommandPoolCreateInfo commandPoolCI{};
commandPoolCI.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;

// 命令池必须绑定到特定的队列族 (Queue Family)。
// 这里的队列族必须与你创建的渲染队列(如图形队列)一致,否则 GPU 无法执行此池中的命令。
commandPoolCI.queueFamilyIndex = swapChain.queueNodeIndex;

// !!! 关键标志位 !!!
// VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 允许我们重置 (Reset) 池中的缓冲区。
// 如果不设置此项,缓冲区一旦分配就很难更改,重置位使得我们可以在每一帧重复利用现有的缓冲区,
// 而不需要频繁销毁和重新创建,这能极大提升 CPU 性能。
commandPoolCI.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;

// 创建命令池
VK_CHECK_RESULT(vkCreateCommandPool(device, &commandPoolCI, nullptr, &commandPool));

// 2. 分配命令缓冲区 (Command Buffer)
// 从刚才创建的命令池中,为“每一帧”分配一个对应的缓冲区。
VkCommandBufferAllocateInfo cmdBufAllocateInfo = vks::initializers::commandBufferAllocateInfo(
commandPool,
VK_COMMAND_BUFFER_LEVEL_PRIMARY, // PRIMARY 类型:可直接提交到队列,不同于 Secondary buffer (被调用者)
MAX_CONCURRENT_FRAMES); // 分配数量:与“最大并发帧数”一致

// 执行内存分配
VK_CHECK_RESULT(
vkAllocateCommandBuffers(device, &cmdBufAllocateInfo, commandBuffers.data()));
}

// 准备用于索引三角形的顶点和索引缓冲区
// 同时使用暂存(staging)将它们上传到设备本地内存,并初始化顶点输入和属性绑定以匹配顶点着色器
void createVertexBuffer() {
// 关于 Vulkan 中内存管理的说明:
// 这是一个非常复杂的主题。虽然在示例应用程序中进行小的独立内存分配是可以的,
// 但在实际应用中不应这样做,在实际应用中,您应该一次分配大块内存。

// 设置顶点
std::vector <Vertex> vertexBuffer{
{{1.0f, 1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}},
{{-1.0f, 1.0f, 0.0f}, {0.0f, 1.0f, 0.0f}},
{{0.0f, -1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}
};
uint32_t vertexBufferSize = static_cast<uint32_t>(vertexBuffer.size()) * sizeof(Vertex);

// 设置索引
std::vector <uint32_t> indexBuffer{0, 1, 2};
indices.count = static_cast<uint32_t>(indexBuffer.size());
uint32_t indexBufferSize = indices.count * sizeof(uint32_t);

VkMemoryAllocateInfo memAlloc{};
memAlloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
VkMemoryRequirements memReqs;

// 像顶点和索引缓冲区这样的静态数据应该存储在设备内存中,以便 GPU 进行优化(且最快)的访问
//
// 为了实现这一点,我们使用所谓的“暂存缓冲区(staging buffers)”:
// - 创建一个对主机可见(并且可以映射)的缓冲区
// - 将数据复制到此缓冲区
// - 创建另一个大小相同的设备本地(VRAM)缓冲区
// - 使用命令缓冲区将数据从主机复制到设备
// - 删除主机可见的(暂存)缓冲区
// - 使用设备本地缓冲区进行渲染
//
// 注意:在主机 (CPU) 和 GPU 共享相同内存的统一内存架构上,暂存是不必要的
// 为了使此示例易于理解,这里没有进行相关的检查

struct StagingBuffer {
VkDeviceMemory memory;
VkBuffer buffer;
};

struct {
StagingBuffer vertices;
StagingBuffer indices;
} stagingBuffers{};

void *data;

// 顶点缓冲区
VkBufferCreateInfo vertexBufferInfoCI{};
vertexBufferInfoCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
vertexBufferInfoCI.size = vertexBufferSize;
// 缓冲区用作复制源
vertexBufferInfoCI.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
// 创建一个主机可见的缓冲区来复制顶点数据(暂存缓冲区)
VK_CHECK_RESULT(vkCreateBuffer(device, &vertexBufferInfoCI, nullptr,
&stagingBuffers.vertices.buffer));
vkGetBufferMemoryRequirements(device, stagingBuffers.vertices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
// 请求一种主机可见的内存类型,可用于将数据复制到其中
// 同时请求它是一致的(coherent),这样在取消映射缓冲区后,写入内容对 GPU 是立即可见的
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK_CHECK_RESULT(
vkAllocateMemory(device, &memAlloc, nullptr, &stagingBuffers.vertices.memory));
// 映射并复制
VK_CHECK_RESULT(
vkMapMemory(device, stagingBuffers.vertices.memory, 0, memAlloc.allocationSize, 0,
&data));
memcpy(data, vertexBuffer.data(), vertexBufferSize);
vkUnmapMemory(device, stagingBuffers.vertices.memory);
VK_CHECK_RESULT(vkBindBufferMemory(device, stagingBuffers.vertices.buffer,
stagingBuffers.vertices.memory, 0));

// 创建一个设备本地缓冲区,(主机本地的)顶点数据将被复制到其中,并用于渲染
vertexBufferInfoCI.usage =
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VK_CHECK_RESULT(vkCreateBuffer(device, &vertexBufferInfoCI, nullptr, &vertices.buffer));
vkGetBufferMemoryRequirements(device, vertices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAlloc, nullptr, &vertices.memory));
VK_CHECK_RESULT(vkBindBufferMemory(device, vertices.buffer, vertices.memory, 0));

// 索引缓冲区
VkBufferCreateInfo indexbufferCI{};
indexbufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
indexbufferCI.size = indexBufferSize;
indexbufferCI.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
// 将索引数据复制到对主机可见的缓冲区(暂存缓冲区)
VK_CHECK_RESULT(
vkCreateBuffer(device, &indexbufferCI, nullptr, &stagingBuffers.indices.buffer));
vkGetBufferMemoryRequirements(device, stagingBuffers.indices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK_CHECK_RESULT(
vkAllocateMemory(device, &memAlloc, nullptr, &stagingBuffers.indices.memory));
VK_CHECK_RESULT(
vkMapMemory(device, stagingBuffers.indices.memory, 0, indexBufferSize, 0, &data));
memcpy(data, indexBuffer.data(), indexBufferSize);
vkUnmapMemory(device, stagingBuffers.indices.memory);
VK_CHECK_RESULT(vkBindBufferMemory(device, stagingBuffers.indices.buffer,
stagingBuffers.indices.memory, 0));

// 创建仅对设备可见的目标缓冲区
indexbufferCI.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VK_CHECK_RESULT(vkCreateBuffer(device, &indexbufferCI, nullptr, &indices.buffer));
vkGetBufferMemoryRequirements(device, indices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAlloc, nullptr, &indices.memory));
VK_CHECK_RESULT(vkBindBufferMemory(device, indices.buffer, indices.memory, 0));

// 缓冲区复制必须提交到队列,因此我们需要一个命令缓冲区
// 注意:某些设备提供专用的传输队列(仅设置了传输位),在进行大量复制时可能会更快
VkCommandBuffer copyCmd;

VkCommandBufferAllocateInfo cmdBufAllocateInfo{};
cmdBufAllocateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
cmdBufAllocateInfo.commandPool = commandPool;
cmdBufAllocateInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cmdBufAllocateInfo.commandBufferCount = 1;
VK_CHECK_RESULT(vkAllocateCommandBuffers(device, &cmdBufAllocateInfo, &copyCmd));

VkCommandBufferBeginInfo cmdBufInfo = vks::initializers::commandBufferBeginInfo();
VK_CHECK_RESULT(vkBeginCommandBuffer(copyCmd, &cmdBufInfo));
// 将缓冲区区域复制放入命令缓冲区
VkBufferCopy copyRegion{};
// 顶点缓冲区
copyRegion.size = vertexBufferSize;
vkCmdCopyBuffer(copyCmd, stagingBuffers.vertices.buffer, vertices.buffer, 1, &copyRegion);
// 索引缓冲区
copyRegion.size = indexBufferSize;
vkCmdCopyBuffer(copyCmd, stagingBuffers.indices.buffer, indices.buffer, 1, &copyRegion);
VK_CHECK_RESULT(vkEndCommandBuffer(copyCmd));

// 将命令缓冲区提交到队列以完成复制
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &copyCmd;

// 创建栅栏以确保命令缓冲区已完成执行
VkFenceCreateInfo fenceCI{};
fenceCI.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceCI.flags = 0;
VkFence fence;
VK_CHECK_RESULT(vkCreateFence(device, &fenceCI, nullptr, &fence));

// 提交到队列
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, fence));
// 等待栅栏发出信号,表明命令缓冲区已完成执行
VK_CHECK_RESULT(vkWaitForFences(device, 1, &fence, VK_TRUE, DEFAULT_FENCE_TIMEOUT));

vkDestroyFence(device, fence, nullptr);
vkFreeCommandBuffers(device, commandPool, 1, &copyCmd);

// 销毁暂存缓冲区
// 注意:在复制提交并执行之前,不得删除暂存缓冲区
vkDestroyBuffer(device, stagingBuffers.vertices.buffer, nullptr);
vkFreeMemory(device, stagingBuffers.vertices.memory, nullptr);
vkDestroyBuffer(device, stagingBuffers.indices.buffer, nullptr);
vkFreeMemory(device, stagingBuffers.indices.memory, nullptr);
}

// 描述符是从池中分配的,该池告诉实现我们将(最多)使用多少个以及什么类型的描述符
void createDescriptorPool() {
// 我们需要告诉 API 每个类型的最大请求描述符数量
VkDescriptorPoolSize descriptorTypeCounts[1]{};
// 此示例仅使用一种描述符类型(Uniform 缓冲区)
descriptorTypeCounts[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
// 我们每帧有一个缓冲区(因此也有一个描述符)
descriptorTypeCounts[0].descriptorCount = MAX_CONCURRENT_FRAMES;
// 对于其他类型,您需要在类型计数列表中添加新条目
// 例如,对于两个组合图像采样器:
// typeCounts[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
// typeCounts[1].descriptorCount = 2;

// 创建全局描述符池
// 此示例中使用的所有描述符都是从此池中分配的
VkDescriptorPoolCreateInfo descriptorPoolCI{};
descriptorPoolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descriptorPoolCI.pNext = nullptr;
descriptorPoolCI.poolSizeCount = 1;
descriptorPoolCI.pPoolSizes = descriptorTypeCounts;
// 设置可以从此池请求的最大描述符集数量(超出此限制的请求将导致错误)
// 我们的示例将为每帧的每个 Uniform 缓冲区创建一个集
descriptorPoolCI.maxSets = MAX_CONCURRENT_FRAMES;
VK_CHECK_RESULT(
vkCreateDescriptorPool(device, &descriptorPoolCI, nullptr, &descriptorPool));
}

void createDescriptorSetLayout() {
// 1. 定义具体的“绑定(Binding)”规范
// VkDescriptorSetLayoutBinding 描述了接口中的一个“插槽”
VkDescriptorSetLayoutBinding layoutBinding{};

// 指定类型:这里是 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER (UBO)
// 意味着着色器会在这里找一块由 CPU 更新的全局数据(如变换矩阵)
layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;

// 指定数量:1 表示这个绑定点对应一个单独的缓冲区
// 也可以是数组(例如多个 UBO),这里设为 1 即可
layoutBinding.descriptorCount = 1;

// 指定阶段:VK_SHADER_STAGE_VERTEX_BIT
// 这一行非常重要!它告诉驱动程序:这个数据只会被顶点着色器使用。
// 驱动程序利用这个信息进行优化,它不会把数据推送到片元着色器(Fragment Shader)中。
layoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

// 纹理采样器相关(此处为 nullptr,因为我们目前只用 UBO)
layoutBinding.pImmutableSamplers = nullptr;

// 2. 创建布局的描述信息
// 这将所有的“绑定规范”打包,准备告诉驱动我们要创建一个布局对象
VkDescriptorSetLayoutCreateInfo descriptorLayoutCI{};
descriptorLayoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
descriptorLayoutCI.pNext = nullptr;

// 绑定点总数:这里我们只有一个 UBO,所以是 1
descriptorLayoutCI.bindingCount = 1;

// 指向上面定义的数组(绑定列表)
descriptorLayoutCI.pBindings = &layoutBinding;

// 3. 执行创建
// 这一步在内存中构建了一个“蓝图”对象,后续它将作为 Pipeline 创建的一部分
VK_CHECK_RESULT(vkCreateDescriptorSetLayout(device, &descriptorLayoutCI, nullptr, &descriptorSetLayout));
}

// 着色器使用指向我们 Uniform 缓冲区的描述符集来访问数据
// 描述符集使用了上面创建的描述符集布局
void createDescriptorSets() {
// 从全局描述符池中为每帧分配一个描述符集
for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
VkDescriptorSetAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
allocInfo.descriptorPool = descriptorPool;
allocInfo.descriptorSetCount = 1;
allocInfo.pSetLayouts = &descriptorSetLayout;
VK_CHECK_RESULT(
vkAllocateDescriptorSets(device, &allocInfo, &uniformBuffers[i].descriptorSet));

// 更新确定着色器绑定点的描述符集
// 对于着色器中使用的每个绑定点,都需要有一个与该绑定点匹配的描述符集
VkWriteDescriptorSet writeDescriptorSet{};

// 缓冲区的信息使用描述符信息结构传递
VkDescriptorBufferInfo bufferInfo{};
bufferInfo.buffer = uniformBuffers[i].buffer;
bufferInfo.range = sizeof(ShaderData);

// 绑定 0:Uniform 缓冲区
writeDescriptorSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writeDescriptorSet.dstSet = uniformBuffers[i].descriptorSet;
writeDescriptorSet.descriptorCount = 1;
writeDescriptorSet.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writeDescriptorSet.pBufferInfo = &bufferInfo;
writeDescriptorSet.dstBinding = 0;
vkUpdateDescriptorSets(device, 1, &writeDescriptorSet, 0, nullptr);
}
}

// 创建我们帧缓冲区使用的深度(和模板)缓冲区附件
// 注意:基类中的虚函数重写,并在 VulkanExampleBase::prepare 中被调用
void setupDepthStencil() {
// 创建一个用作深度模板附件的最佳图像
VkImageCreateInfo imageCI{};
imageCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageCI.imageType = VK_IMAGE_TYPE_2D;
imageCI.format = depthFormat;
// 使用示例的高度和宽度
imageCI.extent = {width, height, 1};
imageCI.mipLevels = 1;
imageCI.arrayLayers = 1;
imageCI.samples = VK_SAMPLE_COUNT_1_BIT;
imageCI.tiling = VK_IMAGE_TILING_OPTIMAL;
imageCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT;
imageCI.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
VK_CHECK_RESULT(vkCreateImage(device, &imageCI, nullptr, &depthStencil.image));

// 为图像分配内存(设备本地)并将其绑定到我们的图像
VkMemoryAllocateInfo memAlloc{};
memAlloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
VkMemoryRequirements memReqs;
vkGetImageMemoryRequirements(device, depthStencil.image, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAlloc, nullptr, &depthStencil.memory));
VK_CHECK_RESULT(vkBindImageMemory(device, depthStencil.image, depthStencil.memory, 0));

// 为深度模板图像创建视图
// 在 Vulkan 中,图像不能直接访问,而是通过子资源范围描述的视图访问
// 这允许同一个图像有多个具有不同范围的视图(例如,用于不同的层)
VkImageViewCreateInfo depthStencilViewCI{};
depthStencilViewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
depthStencilViewCI.viewType = VK_IMAGE_VIEW_TYPE_2D;
depthStencilViewCI.format = depthFormat;
depthStencilViewCI.subresourceRange = {};
depthStencilViewCI.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
// 模板方面(Stencil aspect)仅应在深度+模板格式上设置 (VK_FORMAT_D16_UNORM_S8_UINT..VK_FORMAT_D32_SFLOAT_S8_UINT)
if (depthFormat >= VK_FORMAT_D16_UNORM_S8_UINT) {
depthStencilViewCI.subresourceRange.aspectMask |= VK_IMAGE_ASPECT_STENCIL_BIT;
}
depthStencilViewCI.subresourceRange.baseMipLevel = 0;
depthStencilViewCI.subresourceRange.levelCount = 1;
depthStencilViewCI.subresourceRange.baseArrayLayer = 0;
depthStencilViewCI.subresourceRange.layerCount = 1;
depthStencilViewCI.image = depthStencil.image;
VK_CHECK_RESULT(
vkCreateImageView(device, &depthStencilViewCI, nullptr, &depthStencil.view));
}

// 为每个交换链图像创建一个帧缓冲区
// 注意:基类中的虚函数重写,并在 VulkanExampleBase::prepare 中被调用
void setupFrameBuffer() {
// 为交换链中的每个图像创建一个帧缓冲区
frameBuffers.resize(swapChain.images.size());
for (size_t i = 0; i < frameBuffers.size(); i++) {
std::array<VkImageView, 2> attachments{};
// 颜色附件是交换链图像的视图
attachments[0] = swapChain.imageViews[i];
// 深度/模板附件对于所有帧缓冲区都是相同的,这是基于深度在当前 GPU 上的工作方式
attachments[1] = depthStencil.view;

VkFramebufferCreateInfo frameBufferCI{};
frameBufferCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
// 所有帧缓冲区使用相同的渲染通道设置
frameBufferCI.renderPass = renderPass;
frameBufferCI.attachmentCount = static_cast<uint32_t>(attachments.size());
frameBufferCI.pAttachments = attachments.data();
frameBufferCI.width = width;
frameBufferCI.height = height;
frameBufferCI.layers = 1;
// 创建帧缓冲区
VK_CHECK_RESULT(vkCreateFramebuffer(device, &frameBufferCI, nullptr, &frameBuffers[i]));
}
}

// 渲染通道设置
// 渲染通道(Render passes)是 Vulkan 中的一个新概念。它们描述了渲染过程中使用的附件,并且可能包含具有附件依赖关系的多个子通道
// 这允许驱动程序预先知道渲染的样子,并且是一个很好的优化机会,特别是在基于瓦片的渲染器上(具有多个子通道)
// 使用子通道依赖关系还会为所使用的附件添加隐式布局转换,因此我们不需要添加显式的图像内存屏障来转换它们
// 注意:基类中的虚函数重写,并在 VulkanExampleBase::prepare 中被调用
void setupRenderPass() {
// 此示例将使用一个带有单个子通道的渲染通道

// 此渲染通道使用的附件描述符
std::array<VkAttachmentDescription, 2> attachments{};

// 颜色附件
attachments[0].format = swapChain.colorFormat; // 使用交换链选择的颜色格式
attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; // 在此示例中我们不使用多重采样
attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 在渲染通道开始时清除此附件
attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; // 在渲染通道结束后保留其内容(用于显示)
attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 我们不使用模板,因此不关心加载
attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 存储也一样
attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 渲染通道开始时的布局。初始并不重要,所以我们使用未定义(undefined)
attachments[0].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; // 渲染通道结束后附件转换到的布局
// 因为我们想将颜色缓冲区呈现给交换链,所以我们转换为 PRESENT_KHR

// 深度附件
attachments[1].format = depthFormat; // 在示例基类中选择了合适的深度格式
attachments[1].samples = VK_SAMPLE_COUNT_1_BIT;
attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 在第一个子通道开始时清除深度
attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 渲染通道结束后我们不需要深度(DONT_CARE 可能会带来更好的性能)
attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 无模板
attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 无模板
attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 渲染通道开始时的布局。初始并不重要,所以我们使用未定义
attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; // 转换为深度/模板附件

// 设置附件引用
VkAttachmentReference colorReference{};
colorReference.attachment = 0; // 附件 0 是颜色
colorReference.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; // 子通道期间用作颜色的附件布局

VkAttachmentReference depthReference{};
depthReference.attachment = 1; // 附件 1 是颜色
depthReference.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; // 子通道期间用作深度/模板的附件

// 设置单个子通道引用
VkSubpassDescription subpassDescription{};
subpassDescription.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpassDescription.colorAttachmentCount = 1; // 子通道使用一个颜色附件
subpassDescription.pColorAttachments = &colorReference; // 引用槽 0 中的颜色附件
subpassDescription.pDepthStencilAttachment = &depthReference; // 引用槽 1 中的深度附件
subpassDescription.inputAttachmentCount = 0; // 输入附件可用于从前一个子通道的内容中采样
subpassDescription.pInputAttachments = nullptr; // (此示例不使用输入附件)
subpassDescription.preserveAttachmentCount = 0; // 保留附件可用于在子通道中循环(并保留)附件
subpassDescription.pPreserveAttachments = nullptr; // (此示例不使用保留附件)
subpassDescription.pResolveAttachments = nullptr; // 解析附件在子通道结束时解析,可用于例如多重采样

// 设置子通道依赖关系
// 这些将添加附件描述中指定的隐式附件布局转换
// 实际使用布局通过附件引用中指定的布局保留
// 每个子通道依赖关系将在 srcStageMask、dstStageMask、srcAccessMask、dstAccessMask(以及设置了 dependencyFlags)
// 描述的源和目标子通道之间引入内存和执行依赖关系
// 注意:VK_SUBPASS_EXTERNAL 是一个特殊的常量,指的是在实际渲染通道之外执行的所有命令)
std::array<VkSubpassDependency, 2> dependencies{};

// 对深度和颜色附件进行从最终布局到初始布局的转换
// 深度附件
dependencies[0].srcSubpass = VK_SUBPASS_EXTERNAL;
dependencies[0].dstSubpass = 0;
dependencies[0].srcStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[0].dstStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[0].srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependencies[0].dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT |
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT;
dependencies[0].dependencyFlags = 0;
// 颜色附件
dependencies[1].srcSubpass = VK_SUBPASS_EXTERNAL;
dependencies[1].dstSubpass = 0;
dependencies[1].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependencies[1].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependencies[1].srcAccessMask = 0;
dependencies[1].dstAccessMask =
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_READ_BIT;
dependencies[1].dependencyFlags = 0;

// 创建实际的渲染通道
VkRenderPassCreateInfo renderPassCI{};
renderPassCI.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassCI.attachmentCount = static_cast<uint32_t>(attachments.size()); // 此渲染通道使用的附件数量
renderPassCI.pAttachments = attachments.data(); // 渲染通道使用的附件描述
renderPassCI.subpassCount = 1; // 此示例中我们仅使用一个子通道
renderPassCI.pSubpasses = &subpassDescription; // 该子通道的描述
renderPassCI.dependencyCount = static_cast<uint32_t>(dependencies.size()); // 子通道依赖关系的数量
renderPassCI.pDependencies = dependencies.data(); // 渲染通道使用的子通道依赖关系
VK_CHECK_RESULT(vkCreateRenderPass(device, &renderPassCI, nullptr, &renderPass));
}


// Vulkan 从名为 SPIR-V 的即时二进制表示形式加载着色器
// 着色器使用参考 glslang 编译器从例如 GLSL 离线编译而来
// 此函数从二进制文件加载此类着色器并返回着色器模块结构
VkShaderModule loadSPIRVShader(std::string filename) {
size_t shaderSize;
char *shaderCode{nullptr};

#if defined(__ANDROID__)
// 从压缩的 asset 加载着色器
AAsset* asset = AAssetManager_open(androidApp->activity->assetManager, filename.c_str(), AASSET_MODE_STREAMING);
assert(asset);
shaderSize = AAsset_getLength(asset);
assert(shaderSize > 0);

shaderCode = new char[shaderSize];
AAsset_read(asset, shaderCode, shaderSize);
AAsset_close(asset);
#else
std::ifstream is(filename, std::ios::binary | std::ios::in | std::ios::ate);

if (is.is_open()) {
shaderSize = is.tellg();
is.seekg(0, std::ios::beg);
// 将文件内容复制到缓冲区
shaderCode = new char[shaderSize];
is.read(shaderCode, shaderSize);
is.close();
assert(shaderSize > 0);
}
#endif
if (shaderCode) {
// 创建一个将用于流水线创建的新着色器模块
VkShaderModuleCreateInfo shaderModuleCI{};
shaderModuleCI.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
shaderModuleCI.codeSize = shaderSize;
shaderModuleCI.pCode = (uint32_t *) shaderCode;

VkShaderModule shaderModule;
VK_CHECK_RESULT(vkCreateShaderModule(device, &shaderModuleCI, nullptr, &shaderModule));

delete[] shaderCode;

return shaderModule;
} else {
std::cerr << "Error: Could not open shader file \"" << filename << "\"" << std::endl;
return VK_NULL_HANDLE;
}
}

void createPipelines() {
// 创建用于生成基于此描述符集布局的渲染流水线的流水线布局
// 在更复杂的场景中,您将有不同的流水线布局用于可以重用的不同描述符集布局
VkPipelineLayoutCreateInfo pipelineLayoutCI{};
pipelineLayoutCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutCI.pNext = nullptr;
pipelineLayoutCI.setLayoutCount = 1;
pipelineLayoutCI.pSetLayouts = &descriptorSetLayout;
VK_CHECK_RESULT(
vkCreatePipelineLayout(device, &pipelineLayoutCI, nullptr, &pipelineLayout));

// 创建此示例中使用的图形流水线
// Vulkan 使用渲染流水线的概念来封装固定状态,取代了 OpenGL 复杂的有限状态机
// 然后将流水线存储在 GPU 上并进行哈希处理,使得流水线更改非常快
// 注意:仍然有一些动态状态不是流水线的直接部分(但使用了它们的信息)

VkGraphicsPipelineCreateInfo pipelineCI{};
pipelineCI.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
// 此流水线使用的布局(可以在使用相同布局的多个流水线之间共享)
pipelineCI.layout = pipelineLayout;
// 此流水线附加到的渲染通道
pipelineCI.renderPass = renderPass;

// 构建构成流水线的不同状态

// 输入装配状态描述了如何装配图元
// 此流水线将顶点数据装配为三角形列表(尽管我们只使用一个三角形)
VkPipelineInputAssemblyStateCreateInfo inputAssemblyStateCI{};
inputAssemblyStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssemblyStateCI.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;

// 光栅化状态
VkPipelineRasterizationStateCreateInfo rasterizationStateCI{};
rasterizationStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizationStateCI.polygonMode = VK_POLYGON_MODE_FILL;
rasterizationStateCI.cullMode = VK_CULL_MODE_NONE;
rasterizationStateCI.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
rasterizationStateCI.depthClampEnable = VK_FALSE;
rasterizationStateCI.rasterizerDiscardEnable = VK_FALSE;
rasterizationStateCI.depthBiasEnable = VK_FALSE;
rasterizationStateCI.lineWidth = 1.0f;

// 颜色混合状态描述了如何计算混合因子(如果使用)
// 我们每个颜色附件需要一个混合附件状态(即使不使用混合)
VkPipelineColorBlendAttachmentState blendAttachmentState{};
blendAttachmentState.colorWriteMask = 0xf;
blendAttachmentState.blendEnable = VK_FALSE;
VkPipelineColorBlendStateCreateInfo colorBlendStateCI{};
colorBlendStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlendStateCI.attachmentCount = 1;
colorBlendStateCI.pAttachments = &blendAttachmentState;

// 视口状态设置此流水线中使用的视口和裁剪范围的数量
// 注意:这实际上被动态状态覆盖了(见下文)
VkPipelineViewportStateCreateInfo viewportStateCI{};
viewportStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportStateCI.viewportCount = 1;
viewportStateCI.scissorCount = 1;

// 启用动态状态
// 大多数状态被烘焙到流水线中,但仍然有一些动态状态可以在命令缓冲区内更改
// 为了能够更改这些状态,我们需要指定将使用此流水线更改哪些动态状态。它们的实际状态稍后在命令缓冲区中设置。
// 对于此示例,我们将使用动态状态设置视口和裁剪范围
std::vector <VkDynamicState> dynamicStateEnables;
dynamicStateEnables.push_back(VK_DYNAMIC_STATE_VIEWPORT);
dynamicStateEnables.push_back(VK_DYNAMIC_STATE_SCISSOR);
VkPipelineDynamicStateCreateInfo dynamicStateCI{};
dynamicStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamicStateCI.pDynamicStates = dynamicStateEnables.data();
dynamicStateCI.dynamicStateCount = static_cast<uint32_t>(dynamicStateEnables.size());

// 深度和模板状态,包含深度和模板比较与测试操作
// 我们仅使用深度测试,并希望启用深度测试和写入,并以小于或等于进行比较
VkPipelineDepthStencilStateCreateInfo depthStencilStateCI{};
depthStencilStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depthStencilStateCI.depthTestEnable = VK_TRUE;
depthStencilStateCI.depthWriteEnable = VK_TRUE;
depthStencilStateCI.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
depthStencilStateCI.depthBoundsTestEnable = VK_FALSE;
depthStencilStateCI.back.failOp = VK_STENCIL_OP_KEEP;
depthStencilStateCI.back.passOp = VK_STENCIL_OP_KEEP;
depthStencilStateCI.back.compareOp = VK_COMPARE_OP_ALWAYS;
depthStencilStateCI.stencilTestEnable = VK_FALSE;
depthStencilStateCI.front = depthStencilStateCI.back;

// 多重采样状态
// 此示例不使用多重采样(用于抗锯齿),状态仍必须设置并传递给流水线
VkPipelineMultisampleStateCreateInfo multisampleStateCI{};
multisampleStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisampleStateCI.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
multisampleStateCI.pSampleMask = nullptr;

// 顶点输入描述
// 指定流水线的顶点输入参数

// 顶点输入绑定
// 此示例在绑定点 0 使用单个顶点输入绑定(参见 vkCmdBindVertexBuffers)
VkVertexInputBindingDescription vertexInputBinding{};
vertexInputBinding.binding = 0;
vertexInputBinding.stride = sizeof(Vertex);
vertexInputBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

// 输入属性绑定描述了着色器属性位置和内存布局
std::array<VkVertexInputAttributeDescription, 2> vertexInputAttributs{};
// 这些匹配以下着色器布局(参见 triangle.vert):
// layout (location = 0) in vec3 inPos;
// layout (location = 1) in vec3 inColor;
// 属性位置 0:位置
vertexInputAttributs[0].binding = 0;
vertexInputAttributs[0].location = 0;
// 位置属性是三个 32 位有符号 (SFLOAT) 浮点数 (R32 G32 B32)
vertexInputAttributs[0].format = VK_FORMAT_R32G32B32_SFLOAT;
vertexInputAttributs[0].offset = offsetof(Vertex, position);
// 属性位置 1:颜色
vertexInputAttributs[1].binding = 0;
vertexInputAttributs[1].location = 1;
// 颜色属性是三个 32 位有符号 (SFLOAT) 浮点数 (R32 G32 B32)
vertexInputAttributs[1].format = VK_FORMAT_R32G32B32_SFLOAT;
vertexInputAttributs[1].offset = offsetof(Vertex, color);

// 用于流水线创建的顶点输入状态
VkPipelineVertexInputStateCreateInfo vertexInputStateCI{};
vertexInputStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputStateCI.vertexBindingDescriptionCount = 1;
vertexInputStateCI.pVertexBindingDescriptions = &vertexInputBinding;
vertexInputStateCI.vertexAttributeDescriptionCount = 2;
vertexInputStateCI.pVertexAttributeDescriptions = vertexInputAttributs.data();

// 着色器
std::array<VkPipelineShaderStageCreateInfo, 2> shaderStages{};

// 顶点着色器
shaderStages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
// 设置此着色器的流水线阶段
shaderStages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
// 加载二进制 SPIR-V 着色器
shaderStages[0].module = loadSPIRVShader(getShadersPath() + "triangle/triangle.vert.spv");
// 着色器的主要入口点
shaderStages[0].pName = "main";
assert(shaderStages[0].module != VK_NULL_HANDLE);

// 片元着色器
shaderStages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
// 设置此着色器的流水线阶段
shaderStages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
// 加载二进制 SPIR-V 着色器
shaderStages[1].module = loadSPIRVShader(getShadersPath() + "triangle/triangle.frag.spv");
// 着色器的主要入口点
shaderStages[1].pName = "main";
assert(shaderStages[1].module != VK_NULL_HANDLE);

// 设置流水线着色器阶段信息
pipelineCI.stageCount = static_cast<uint32_t>(shaderStages.size());
pipelineCI.pStages = shaderStages.data();

// 将流水线状态分配给流水线创建信息结构
pipelineCI.pVertexInputState = &vertexInputStateCI;
pipelineCI.pInputAssemblyState = &inputAssemblyStateCI;
pipelineCI.pRasterizationState = &rasterizationStateCI;
pipelineCI.pColorBlendState = &colorBlendStateCI;
pipelineCI.pMultisampleState = &multisampleStateCI;
pipelineCI.pViewportState = &viewportStateCI;
pipelineCI.pDepthStencilState = &depthStencilStateCI;
pipelineCI.pDynamicState = &dynamicStateCI;

// 使用指定状态创建渲染流水线
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCI, nullptr,
&pipeline));

// 图形流水线创建后,不再需要着色器模块
vkDestroyShaderModule(device, shaderStages[0].module, nullptr);
vkDestroyShaderModule(device, shaderStages[1].module, nullptr);
}

void createUniformBuffers() {
// 准备并初始化包含着色器 Uniform 的每帧 Uniform 缓冲区块
// OpenGL 中那样的单一 Uniform 在 Vulkan 中已不再存在。所有着色器 Uniform 均通过 Uniform 缓冲区块传递
VkMemoryRequirements memReqs;

// 顶点着色器 Uniform 缓冲区块
VkBufferCreateInfo bufferInfo{};
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.pNext = nullptr;
allocInfo.allocationSize = 0;
allocInfo.memoryTypeIndex = 0;

bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(ShaderData);
// 此缓冲区将用作 Uniform 缓冲区
bufferInfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;

// 创建缓冲区
for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
VK_CHECK_RESULT(
vkCreateBuffer(device, &bufferInfo, nullptr, &uniformBuffers[i].buffer));
// 获取包括大小、对齐和内存类型在内的内存要求
vkGetBufferMemoryRequirements(device, uniformBuffers[i].buffer, &memReqs);
allocInfo.allocationSize = memReqs.size;
// 获取支持主机可见内存访问的内存类型索引
// 大多数实现提供多种内存类型,从正确的类型分配内存至关重要
// 我们还希望缓冲区是主机一致的(host coherent),这样我们就无需在每次更新后刷新(或同步)。
// 注意:这可能会影响性能,因此在定期更新缓冲区的实际应用程序中,您可能不想这样做
allocInfo.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
// 为 Uniform 缓冲区分配内存
VK_CHECK_RESULT(
vkAllocateMemory(device, &allocInfo, nullptr, &(uniformBuffers[i].memory)));
// 将内存绑定到缓冲区
VK_CHECK_RESULT(
vkBindBufferMemory(device, uniformBuffers[i].buffer, uniformBuffers[i].memory,
0));
// 我们映射一次缓冲区,这样我们就可以更新它而无需再次映射
VK_CHECK_RESULT(vkMapMemory(device, uniformBuffers[i].memory, 0, sizeof(ShaderData), 0,
(void **) &uniformBuffers[i].mapped));
}

}

void prepare() {
// 调用基类初始化方法(通常负责创建 Vulkan 实例、逻辑设备、交换链等基础资源)
VulkanExampleBase::prepare();
// 创建同步原语(如信号量 Semaphores 和栅栏 Fences,用于控制渲染流程同步)
createSynchronizationPrimitives();
// 创建命令缓冲区(用于记录绘图命令)
createCommandBuffers();
// 创建并填充顶点缓冲区(上传顶点数据到 GPU)
createVertexBuffer();
// 创建统一缓冲区(Uniform Buffers,用于存储着色器所需的常量数据)
createUniformBuffers();
// 创建描述符集布局(定义着色器如何访问缓冲区和纹理)
createDescriptorSetLayout();
// 创建描述符池(用于分配描述符集)
createDescriptorPool();
// 创建描述符集(将具体的缓冲区或图像绑定到描述符布局中)
createDescriptorSets();
// 创建图形管线(配置着色器阶段、固定功能状态等)
createPipelines();
// 标记初始化准备已完成
prepared = true;
}

virtual void render() {
if (!prepared)
return;

// 使用栅栏等待,直到命令缓冲区完成执行后再再次使用它
vkWaitForFences(device, 1, &waitFences[currentFrame], VK_TRUE, UINT64_MAX);
VK_CHECK_RESULT(vkResetFences(device, 1, &waitFences[currentFrame]));

// 从实现中获取下一个交换链图像
// 请注意,实现可以以任何顺序返回图像,因此我们必须使用获取函数,不能自行循环图像/imageIndex
uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(device, swapChain.swapChain, UINT64_MAX,
presentCompleteSemaphores[currentFrame],
VK_NULL_HANDLE, &imageIndex);
if (result == VK_ERROR_OUT_OF_DATE_KHR) {
windowResize();
return;
} else if ((result != VK_SUCCESS) && (result != VK_SUBOPTIMAL_KHR)) {
throw "Could not acquire the next swap chain image!";
}

// 更新下一帧的 Uniform 缓冲区
ShaderData shaderData{};
shaderData.projectionMatrix = camera.matrices.perspective;
shaderData.viewMatrix = camera.matrices.view;
shaderData.modelMatrix = glm::mat4(1.0f);

// 将当前矩阵复制到当前帧的 Uniform 缓冲区
// 注意:由于我们为 Uniform 缓冲区请求了主机一致的内存类型,因此该写入对 GPU 是立即可见的
memcpy(uniformBuffers[currentFrame].mapped, &shaderData, sizeof(ShaderData));

// 构建命令缓冲区
// 与 OpenGL 不同,所有渲染命令都被记录到命令缓冲区中,然后提交到队列
// 这允许在单独的线程中预先生成工作
// 对于基本命令缓冲区(如本示例),记录速度非常快,因此无需卸载此操作

vkResetCommandBuffer(commandBuffers[currentFrame], 0);

VkCommandBufferBeginInfo cmdBufInfo{};
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

// 为所有 loadOp 设置为 clear 的帧缓冲区附件设置清除值
// 我们使用两个附件(颜色和深度),它们在子通道开始时被清除,因此我们需要为两者设置清除值
VkClearValue clearValues[2]{};
clearValues[0].color = {{0.0f, 0.0f, 0.2f, 1.0f}};
clearValues[1].depthStencil = {1.0f, 0};

VkRenderPassBeginInfo renderPassBeginInfo{};
renderPassBeginInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassBeginInfo.pNext = nullptr;
renderPassBeginInfo.renderPass = renderPass;
renderPassBeginInfo.renderArea.offset.x = 0;
renderPassBeginInfo.renderArea.offset.y = 0;
renderPassBeginInfo.renderArea.extent.width = width;
renderPassBeginInfo.renderArea.extent.height = height;
renderPassBeginInfo.clearValueCount = 2;
renderPassBeginInfo.pClearValues = clearValues;
renderPassBeginInfo.framebuffer = frameBuffers[imageIndex];

const VkCommandBuffer commandBuffer = commandBuffers[currentFrame];
VK_CHECK_RESULT(vkBeginCommandBuffer(commandBuffer, &cmdBufInfo));

// 开始基类通过我们的默认渲染通道设置指定的第一个子通道
// 这将清除颜色和深度附件
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE);
// 更新动态视口状态
VkViewport viewport{};
viewport.height = (float) height;
viewport.width = (float) width;
viewport.minDepth = (float) 0.0f;
viewport.maxDepth = (float) 1.0f;
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
// 更新动态裁剪范围状态
VkRect2D scissor{};
scissor.extent.width = width;
scissor.extent.height = height;
scissor.offset.x = 0;
scissor.offset.y = 0;
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);
// 绑定当前帧 Uniform 缓冲区的描述符集,以便着色器在此绘制中使用该缓冲区的数据
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0,
1, &uniformBuffers[currentFrame].descriptorSet, 0, nullptr);
// 绑定渲染流水线
// 流水线(状态对象)包含渲染流水线的所有状态,绑定它将设置流水线创建时指定的所有状态
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
// 绑定三角形顶点缓冲区(包含位置和颜色)
VkDeviceSize offsets[1]{0};
vkCmdBindVertexBuffers(commandBuffer, 0, 1, &vertices.buffer, offsets);
// 绑定三角形索引缓冲区
vkCmdBindIndexBuffer(commandBuffer, indices.buffer, 0, VK_INDEX_TYPE_UINT32);
// 绘制索引三角形
vkCmdDrawIndexed(commandBuffer, indices.count, 1, 0, 0, 0);
vkCmdEndRenderPass(commandBuffer);
// 结束渲染通道将添加一个隐式屏障,将帧缓冲区颜色附件转换为
// VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,以便呈现给窗口系统
VK_CHECK_RESULT(vkEndCommandBuffer(commandBuffer));

// 将命令缓冲区提交到图形队列

// 队列提交将等待的流水线阶段(通过 pWaitSemaphores)
VkPipelineStageFlags waitStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
// 提交信息结构指定了一个命令缓冲区队列提交批次
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.pWaitDstStageMask = &waitStageMask; // 指向流水线阶段列表的指针,信号量等待将在这些阶段发生
submitInfo.pCommandBuffers = &commandBuffer; // 在此批次(提交)中执行的命令缓冲区
submitInfo.commandBufferCount = 1; // 我们提交单个命令缓冲区

// 提交的命令缓冲区开始执行前要等待的信号量
submitInfo.pWaitSemaphores = &presentCompleteSemaphores[currentFrame];
submitInfo.waitSemaphoreCount = 1;
// 命令缓冲区完成后要发出的信号量
submitInfo.pSignalSemaphores = &renderCompleteSemaphores[currentFrame];
submitInfo.signalSemaphoreCount = 1;

// 提交到图形队列,并传递一个等待栅栏
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, waitFences[currentFrame]));

// 将当前帧缓冲区呈现给交换链
// 将命令缓冲区提交产生的信号量从提交信息中作为交换链呈现的等待信号量传递
// 这确保了在所有命令提交之前,图像不会呈现给窗口系统

VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = &renderCompleteSemaphores[currentFrame];
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = &swapChain.swapChain;
presentInfo.pImageIndices = &imageIndex;
result = vkQueuePresentKHR(queue, &presentInfo);

if ((result == VK_ERROR_OUT_OF_DATE_KHR) || (result == VK_SUBOPTIMAL_KHR)) {
windowResize();
} else if (result != VK_SUCCESS) {
throw "Could not present the image to the swap chain!";
}

// 根据最大并发帧数选择要渲染的下一帧
currentFrame = (currentFrame + 1) % MAX_CONCURRENT_FRAMES;
}
};

// 操作系统特定的主入口点
// 大部分代码库在不同的支持操作系统之间共享,但消息处理等方面存在差异

#if defined(_WIN32)
// Windows 入口点
VulkanExample *vulkanExample;
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
if (vulkanExample != NULL)
{
vulkanExample->handleMessages(hWnd, uMsg, wParam, lParam);
}
return (DefWindowProc(hWnd, uMsg, wParam, lParam));
}
int APIENTRY WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR, _In_ int)
{
for (size_t i = 0; i < __argc; i++) { VulkanExample::args.push_back(__argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow(hInstance, WndProc);
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}

#elif defined(__ANDROID__)
// Android 入口点
VulkanExample *vulkanExample;
void android_main(android_app* state)
{
vulkanExample = new VulkanExample();
state->userData = vulkanExample;
state->onAppCmd = VulkanExample::handleAppCommand;
state->onInputEvent = VulkanExample::handleAppInput;
androidApp = state;
vulkanExample->renderLoop();
delete(vulkanExample);
}
#elif defined(_DIRECT2DISPLAY)

// 带有 Direct to Display WSI 的 Linux 入口点
// Direct to Displays (D2D) 用于嵌入式平台
VulkanExample *vulkanExample;
static void handleEvent()
{
}
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
VulkanExample *vulkanExample;
static void handleEvent(const DFBWindowEvent *event)
{
if (vulkanExample != NULL)
{
vulkanExample->handleEvent(event);
}
}
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
VulkanExample *vulkanExample;
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif defined(__linux__) || defined(__FreeBSD__)

// Linux 入口点
VulkanExample *vulkanExample;
#if defined(VK_USE_PLATFORM_XCB_KHR)
static void handleEvent(const xcb_generic_event_t *event)
{
if (vulkanExample != NULL)
{
vulkanExample->handleEvent(event);
}
}
#else
static void handleEvent()
{
}
#endif
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif (defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT)) && defined(VK_EXAMPLE_XCODE_GENERATED)
VulkanExample *vulkanExample;
int main(const int argc, const char *argv[])
{
@autoreleasepool
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow(nullptr);
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
}
return 0;
}
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
VULKAN_EXAMPLE_MAIN()
#endif
,