对于了解 OpenGL 的小伙伴,快速了解 Vulkan。在看 Vulkan Tutorial 时会非常困惑 Vulkan 对比 OpenGL 为啥会有那么多的操作,看的头大。只有理解了原因,学起来就快多了。

1. Vulkan 基础流程

  1. 创建 Vulkan(VKInstance)
  2. 选择支持的物理设备(VKPhysicalDevice)
  3. 创建逻辑设备和指令队列(VKDevice)和(VKQueue)
  4. 创建窗口,窗口表面和交换链(Swap Chain)
  5. 创建图像视图(VKImageView)。Swap Chain 仅提供底层内存资源(VkImage)。Vulkan 强制要求通过 View 来解释这块内存(例如它是 2D 纹理、深度贴图还是 Cubemap),管线无法直接读取 Image。
  6. 创建渲染流程 RenderPass,指定渲染目标。它定义了渲染目标(颜色/深度附件)的内存加载/存储行为(Load/Store Op)。这对于现代移动端 GPU 的 TBDR(基于图块的延迟渲染)架构优化起着决定性作用。
  7. 为渲染创建帧缓冲(VKFramebuffer)。Framebuffer 是一个「容器」,它将具体的 VKImageView(第 5 步)与 Render Pass 定义的附件槽位(第 6 步)绑定在一起。
  8. 配置图形管线(着色器,视口,光栅化等状态必须提前配置,因为没有默认状态)。在创建管线时,必须传入上述的 Render Pass,以此来验证管线的输出格式与渲染目标是否兼容。
  9. 分配并记录指令缓冲(VKCommandBuffer)
  10. 提交,绘制。包含了将 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 核心)

我们在上个话题中聊到过 RenderPassLoadOpStoreOp,在移动端,这是决定生死的地方。

  • 坚决消灭 LoadOp::LOAD 除非你确实需要读取上一帧的画面(例如累积 Temporal AA,或在现有的 UI 缓冲上继续画),否则永远不要使用 LOAD。使用 LOAD 会强制移动端 GPU 将数据从主存搬运到片上内存,消耗极其宝贵的带宽。每一帧的颜色和深度缓冲起手必须是 CLEARDONT_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_BITHOST_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 的 srcStageMaskdstStageMask 必须精确到具体的着色器阶段。例如,如果只是 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(如 8x864x1)。
  • 谨慎使用 Shared Memory: 移动端的共享内存 (LDS) 容量非常小(有的甚至与 L1 Cache 共享物理存储)。过度依赖 Shared Memory 会直接限制 GPU 能够并行调度的线程组数量。