Vulkan 与 OpenGL 区别
对于了解 OpenGL 的小伙伴,快速了解 Vulkan。在看 Vulkan Tutorial 时会非常困惑 Vulkan 对比 OpenGL 为啥会有那么多的操作,看的头大。只有理解了原因,学起来就快多了。
1. Vulkan 基础流程
- 创建 Vulkan(VKInstance)
- 选择支持的物理设备(VKPhysicalDevice)
- 创建逻辑设备和指令队列(VKDevice)和(VKQueue)
- 创建窗口,窗口表面和交换链(Swap Chain)
- 创建图像视图(VKImageView)。Swap Chain 仅提供底层内存资源(
VkImage)。Vulkan 强制要求通过 View 来解释这块内存(例如它是 2D 纹理、深度贴图还是 Cubemap),管线无法直接读取 Image。 - 创建渲染流程 RenderPass,指定渲染目标。它定义了渲染目标(颜色/深度附件)的内存加载/存储行为(Load/Store Op)。这对于现代移动端 GPU 的 TBDR(基于图块的延迟渲染)架构优化起着决定性作用。
- 为渲染创建帧缓冲(VKFramebuffer)。Framebuffer 是一个「容器」,它将具体的
VKImageView(第 5 步)与 Render Pass 定义的附件槽位(第 6 步)绑定在一起。 - 配置图形管线(着色器,视口,光栅化等状态必须提前配置,因为没有默认状态)。在创建管线时,必须传入上述的 Render Pass,以此来验证管线的输出格式与渲染目标是否兼容。
- 分配并记录指令缓冲(VKCommandBuffer)
- 提交,绘制。包含了将 Command Buffer 提交给 Queue,以及最终请求 Swap Chain 将图像 Present 到屏幕上。
2. Vulkan 与 OpenGL 的区别
1. 输入数据类型
2. 性能对比
| 性能维度 | OpenGL | Vulkan | 性能影响 |
|---|---|---|---|
| 驱动开销 | 高 (厚驱动层) | 低 (薄驱动层) | OpenGL 驱动在运行时承担了大量的状态检查和错误处理工作。Vulkan 将这部分工作剥离(移交至开发期的「校验层」),使得 Release 版本的运行时指令极为精简,极大降低了 CPU 耗时。 |
| 多线程支持 | 弱 (单线程状态机) | 强 (原生多线程) | OpenGL 绑定于单一的渲染上下文,跨线程提交指令极其困难。Vulkan 允许在多个 CPU 核心上并行录制命令缓冲区(Command Buffers),完美契合现代多核处理器(如 i9 级别)的架构,彻底解决 CPU 提交瓶颈。 |
| 状态管理 | 动态验证 (高频开销) | 状态预编译 (PSO) | 在 OpenGL 中,每次切换着色器或混合状态都可能触发底层驱动的重新验证。Vulkan 采用 管线状态对象 (PSO),将着色器、顶点布局等绝大部分状态在初始化时提前编译和固化。这意味着在渲染主循环中,状态切换的开销被降到了最低。 |
| 硬件控制权 | 抽象/自动 (驱动接管) | 精细/显式 (开发者接管) | OpenGL 会「猜测」内存分配和同步的最佳时机,往往导致不可控的性能抖动。Vulkan 要求显式管理内存池、屏障(Barriers)和队列提交,为实现高度优化的 GPU 驱动渲染管线(如类似 Nanite 或复杂 Compute Shader 分发)提供了底层基础。 |
1. 高效驱动与精细控制
- OpenGL 模式: 存在较厚的驱动层(图中云层图案),特点是「高驱动开销,少控制」。所有指令都需要经过复杂的驱动程序转换才能到达硬件。
- Vulkan 模式: 驱动层极薄,支持多条独立的指令流直接下发。特点是「低驱动开销,多线程支持,精细硬件控制,标准着色器」。
2. 状态预编译机制 (PSO)
这是 Vulkan 性能飞跃的关键机制。
- 输入流: 着色器 (Shaders) + 着色布局 + 顶点布局 (Vertex Layout)
- 输出流: 汇聚编译成 管线状态对象 (Pipeline State Object)。
- 核心理念: 大部分管线配置需提前完成,驱动程序借此实现极大优化空间。
3. 对象初始化层次 (Vulkan 架构体系)
Vulkan 的对象模型有着严格的层级关系:
- VkInstance (应用程序连接): 应用程序与 Vulkan 库之间的连接,存储全局扩展。
- VkPhysicalDevice (物理硬件显卡): 选中的系统硬件显卡,用于查询特性和队列支持。
- VkDevice (逻辑接口与队列): 与物理设备交互的逻辑接口,管理队列和资源。(注:物理设备和逻辑设备都会与窗口表面
Surface进行交互以实现上屏)
4. 开发环境与校验层
- 必备开发工具栈: Vulkan SDK (LunarG), GLFW (窗口管理), GLM (线性代数库)。
- 校验层 (Validation Layers): Vulkan 为了极致性能,默认错误检查极少。开发阶段必须手动开启校验层,用于获取调试信息并防止崩溃。这是 Vulkan “显式 API” 哲学的直接体现:开发者需要对自己写的每一行渲染代码负责。
3. Vulkan 中的 RenderPass 理解
要理解 Vulkan 的 RenderPass(渲染通道),最核心的思维转变是要彻底抛弃 OpenGL 那种「走到哪算哪」的状态机模式。
如果要把 RenderPass 具象化,你可以把它想象成一份极其严谨的「工厂流水线加工合同」或 「建筑施工蓝图」。
在传统的 OpenGL 中(使用 FBO,帧缓冲对象),你就像一个随性且微操的老板:你把一块内存(FBO)丢给 GPU 说「开始画」,然后随时喊停,随时说「清理一下屏幕」,或者随时切换渲染目标。GPU 驱动为了配合你的随性,不得不在底层疯狂地做猜测、备份和同步,这也就是为什么 OpenGL 驱动开销那么大的原因。
而在 Vulkan 中,GPU 变成了极其硬核、拒绝一切临时加戏的代工厂。在开工之前,你必须把 RenderPass 这份「合同」拍在它桌子上。
这份「合同」(RenderPass)里规定了什么?
RenderPass 主要由三个核心条款构成,它们构成了现代渲染管线的基石:
1. 附件描述 (Attachments) —— 「需要准备几个集装箱?」
在渲染开始前,你必须声明这次流水线作业需要用到多少个「缓冲区」(比如颜色缓冲、深度缓冲、模板缓冲、或者 G-Buffer 的多个 RT)。
- 在 RenderPass 中,你只是声明这些缓冲区的格式(比如它是个 32 位浮点颜色,还是 24 位深度)和采样数(MSAA),并不绑定具体的内存(那是
Framebuffer的工作)。
2. 加载与存储操作 (Load / Store Ops) —— 「开工前怎么准备?完工后怎么打扫?」
这是 RenderPass 最重要、也是直接决定性能的参数。
- LoadOp (开工前操作):
CLEAR: 开工前,把这个集装箱清空(比如每帧开始时的 Clear Color)。LOAD: 开工前,保留上一帧留下的数据(比如 UI 叠加在 3D 画面上)。DONT_CARE: 随便,里面是什么垃圾数据我都无所谓,反正我马上要全部覆盖重写。
- StoreOp (完工后操作):
STORE: 完工后,把集装箱里的成果写回到物理显存 (VRAM) 里,我后面还要用(比如最终的颜色画面)。DONT_CARE: 完工后,里面的东西直接丢弃。
3. 子通道与依赖 (Subpasses & Dependencies) —— 「流水线分几步?工序怎么交接?」
一个 RenderPass 可以包含多个子工序。比如在实现延迟渲染(Deferred Rendering)时:
- Subpass 0: 渲染几何体,输出 Albedo、Normal、Depth 到 G-Buffer。
- Subpass 1: 读取 G-Buffer,计算光照,输出最终颜色。你必须在 RenderPass 中明确声明这两个步骤,以及 Subpass 1 必须等待 Subpass 0 的特定资源完成。
4. Vulkan 移动端的性能优化
一、RenderPass 与 Subpass 的极致压榨(TBDR 核心)
我们在上个话题中聊到过 RenderPass 的 LoadOp 和 StoreOp,在移动端,这是决定生死的地方。
- 坚决消灭
LoadOp::LOAD: 除非你确实需要读取上一帧的画面(例如累积 Temporal AA,或在现有的 UI 缓冲上继续画),否则永远不要使用LOAD。使用LOAD会强制移动端 GPU 将数据从主存搬运到片上内存,消耗极其宝贵的带宽。每一帧的颜色和深度缓冲起手必须是CLEAR或DONT_CARE。 - 深度的
StoreOp::DONT_CARE: 深度缓冲(Depth/Stencil Buffer)通常只在当前 Pass 的渲染过程中起作用(用于深度测试)。一旦 Pass 结束,务必将其StoreOp设为DONT_CARE。这样驱动会直接在片上内存中丢弃它,避免将庞大的深度数据回写到系统主存。 - Subpass 合并 (Subpass Merging) 与
BY_REGION_BIT: 在实现延迟渲染 (Deferred Rendering) 或复杂的全屏后处理时,将多个步骤写入同一个 RenderPass 的不同 Subpass 中。最关键的是,在设置 Subpass 依赖 (VkSubpassDependency) 时,必须加上VK_DEPENDENCY_BY_REGION_BIT标志。- 底层魔法: 这个标志明确告诉驱动:「下一个 Subpass 只需要读取当前像素(图块)位置的数据」。驱动收到这个信号后,就会将 G-Buffer 的写入和光照计算全部在 16x16 的片上 Tile 内存中一气呵成,彻底消除 G-Buffer 对主存的读写开销。
二、内存分配与 UMA 架构适配
移动端没有独立的显存,CPU 和 GPU 物理上访问的是同一块 LPDDR 内存,只是通过不同的总线和缓存一致性协议在交互。
- Vulkan 内存分配器 (VMA): 不要自己手写
vkAllocateMemory(Vulkan 限制了单台设备最多只能有约 4096 个物理内存分配)。必须引入 AMD 的 VMA (Vulkan Memory Allocator) 库,使用它提供的池化分配(Pool Allocation)。 - 利用 UMA 零拷贝 (Zero-Copy): 在桌面端,将数据从 CPU 传给 GPU 通常需要先写入 Staging Buffer,再通过 Transfer 队列 Copy 到 Device Local 内存。在移动端,你可以直接寻找同时具备
DEVICE_LOCAL_BIT和HOST_VISIBLE_BIT属性的内存堆。将顶点、索引或 Uniform 数据映射(Map)后直接写入,GPU 可以直接从同一块物理地址读取,省去了一次庞大的 Copy 开销。 - 纹理压缩格式: 强制使用 ASTC (Adaptive Scalable Texture Compression)。它是移动端的绝对标准,能在极低的比特率下提供极高的画质,大幅降低采样时的内存带宽。
三、同步机制的精准克制 (Pipeline Barriers)
Vulkan 要求开发者自己管同步,很多开发者为了保证画面不出错,会滥用 Barrier(比如动不动就等 VK_PIPELINE_STAGE_ALL_COMMANDS_BIT),这在移动端是性能杀手。
- 避免打断 Tile 处理: 如果你在一个 RenderPass 中间插入了不当的 Barrier,或者在管线中使用了过于宽泛的等待阶段,会直接打断 GPU 的 Tile 渲染流水线,迫使 GPU 将当前片上内存的数据全部写回主存 (Flush),等待同步完成后,再重新读进来。这种现象被称为 “Tile Thrashing”,会让帧率瞬间减半。
- 精准的 Stage Mask: Barrier 的
srcStageMask和dstStageMask必须精确到具体的着色器阶段。例如,如果只是 Fragment Shader 需要等待 Compute Shader 的结果,dstStageMask就只写FRAGMENT_SHADER_BIT,千万别写BOTTOM_OF_PIPE。
四、描述符集 (Descriptor Sets) 的分频管理
每次绑定 Descriptor Set 都有 CPU 和 GPU 端的验证开销。
- 按更新频率分组: 严格按照数据变化的频率来划分 Set,这是现代渲染管线的标配:
Set 0: Global / Per-Frame (如 ViewProj 矩阵、全局光照数据、时间)。每帧绑一次。Set 1: Per-Pass (如当前 RenderPass 的特定参数)。Set 2: Per-Material (如 PBR 材质参数、纹理)。Set 3: Per-Object (如 Model 矩阵)。每 Draw 绑一次。
- 拥抱 Push Constants: 对于极高频且数据量极小的数据(如单个物体的 Model 矩阵的索引),直接使用 Push Constants。它直接写入 Command Buffer 的寄存器中,根本不需要走内存分配和 Descriptor 绑定,速度极快。
五、Compute Shader 的移动端陷阱
随着高级管线的普及(比如 GPU 驱动渲染),Compute Shader 在移动端用得越来越多,但移动端 GPU 的 Compute 单元并不像桌面端那么「宽」。
- Workgroup Size 缩减: 桌面端常见的
[numthreads(256, 1, 1)]或更大尺寸在移动端极易导致寄存器溢出 (Register Spilling) 或占用率 (Occupancy) 过低。移动端通常建议将 Workgroup Size 控制在 64(如8x8或64x1)。 - 谨慎使用 Shared Memory: 移动端的共享内存 (LDS) 容量非常小(有的甚至与 L1 Cache 共享物理存储)。过度依赖 Shared Memory 会直接限制 GPU 能够并行调度的线程组数量。