Vulkan 初始化与渲染管线:从零画一个三角形

从零开始详解 Vulkan 初始化全流程与渲染管线构建,包含 Instance 创建、物理设备选择、Swap Chain 配置、Graphics Pipeline 构建等核心概念,附带内嵌 SPIR-V 着色器字节码的完整可编译 C++ 代码示例,真正做到无需外部文件即可渲染彩色三角形。

Vulkan 是 Khronos 推出的新一代低开销、跨平台图形与计算 API。与 OpenGL 的隐式驱动管理不同,Vulkan 将内存分配、同步控制、命令提交等职责完全交给开发者,换来的是对 GPU 硬件的极致掌控与多线程友好特性。然而,这种显式设计也意味着极高的初始化复杂度 —— 从一个空白的操作系统窗口到屏幕上出现一个简单三角形,需要跨越二十余个 API 调用步骤。本文将完整拆解这一过程,覆盖从 Instance 创建到 Swap Chain 呈现的全链路,并附带一个内嵌 SPIR-V 着色器字节码的完整可运行 C++ 程序,编译后不依赖任何外部着色器文件即可直接渲染出彩色三角形。


封面总结

Vulkan 的初始化流程是理解现代图形 API 设计哲学的最佳入口。本文以一个完整的 Hello Triangle 程序为载体,串联起 Vulkan 核心对象的创建顺序与依赖关系,帮助读者建立从应用层到 GPU 硬件的端到端认知。掌握这些基础后,方可进一步探索顶点缓冲、Uniform Buffer、深度附件、多重采样等进阶特性。

维度说明
目标使用 Vulkan API 在窗口中绘制一个彩色三角形
代码量完整可编译 C++ 代码约 900 行,核心逻辑内嵌 SPIR-V 着色器字节码
前置知识C++17 基础、了解 GPU 渲染基本流程、矩阵变换概念
编译环境Vulkan SDK 1.3+、GLFW3、支持 C++17 的编译器(GCC/Clang/MSVC)
关键难点Vulkan 对象的显式生命周期管理、Swap Chain 三项配置的正确选择、Render Pass 与 Framebuffer 的关联绑定、Shader Module 从 SPIR-V 字节码的加载

一、Vulkan 的设计哲学与显式控制模型

在深入代码之前,必须先理解 Vulkan 的设计哲学,它是后续所有 API 调用的思想根基。OpenGL 采用隐式状态机模型:驱动在后台管理上下文状态、资源生命周期、内存分配和同步屏障。开发者的调用会被驱动缓存和重排,虽然方便,但也带来了不可预测的性能开销和线程安全隐患。

Vulkan 彻底颠覆了这种模式。每一个对象都需要显式创建和显式销毁,没有全局状态,没有隐式同步。开发者直接操作虚拟内存页、直接定义命令缓冲的录制内容、显式插入 Pipeline Barrier 和 Semaphore 来控制 GPU 执行顺序。这种设计带来了三个核心优势:

第一,多线程原生友好。 由于不存在全局状态机,多个线程可以同时在各自的 Command Pool 中录制命令缓冲,互不干扰。这在多核 CPU 时代至关重要。

第二,驱动开销极低。 驱动不再需要做复杂的启发式优化和状态推断,只需忠实地将开发者提交的命令传递给 GPU 硬件。这显著降低了 API 调用开销(CPU overhead),使得大量 Draw Call 场景下性能远超 OpenGL。

第三,跨平台一致性。 Vulkan 的 API 语义在所有平台上保持统一,Windows 上的代码几乎无需修改即可移植到 Linux 和 Android,这是 OpenGL 由于各平台驱动实现差异巨大而无法比拟的。

当然,代价是显著的:一个最简单的三角形程序,OpenGL 大约需要 30 行代码,而 Vulkan 需要近一千行。这份额外的复杂度不是冗余,而是 Vulkan 将一切控制权交给开发者的必然结果。


二、Vulkan Instance 创建与 Validation Layer

Vulkan 编程的第一步是创建 VkInstance。Instance 是应用程序与 Vulkan 驱动之间的全局入口点,它负责以下工作:枚举可用的物理设备(GPU)、管理全局扩展与校验层、作为后续所有对象创建时的顶层上下文。

创建 Instance 需要填充 VkApplicationInfoVkInstanceCreateInfo 两个结构体。前者描述应用和引擎的名称与版本信息,后者则指定需要加载的扩展(Extensions)和校验层(Validation Layers)。

VkApplicationInfo appInfo{};
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
appInfo.pApplicationName = "Hello Triangle";
appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.pEngineName = "No Engine";
appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.apiVersion = VK_API_VERSION_1_0;

VkInstanceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo = &appInfo;

Vulkan 的几乎所有结构体都要求显式设置 sType 字段,这是一种类型安全措施,防止驱动解析到错误的结构体。apiVersion 字段声明了应用所需的 Vulkan API 版本。Vulkan 遵循向后兼容原则,声明 1.0 版本意味着可以使用所有 1.0 及其扩展功能,但无法使用 1.1 或更高版本才引入的核心特性。

Validation Layer 是 Vulkan 调试体系中最重要的基础设施。它位于驱动与应用之间,拦截并检查每一个 API 调用参数的正确性。与 OpenGL 的内建错误检查不同,Vulkan 在发布版本中完全移除了这些检查,因此调试期必须显式启用 Validation Layer。

// 启用 Khronos 官方验证层
const std::vector<const char*> validationLayers = {
    "VK_LAYER_KHRONOS_validation"
};

VkInstanceCreateInfo 中,通过 enabledLayerCountppEnabledLayerNames 指定要加载的校验层。同时,应通过 vkEnumerateInstanceLayerProperties 在运行时校验该层是否可用,避免在缺少 Vulkan SDK 的机器上直接崩溃。

Debug Messenger 是调试信息的输出通道。创建它需要调用 vkCreateDebugUtilsMessengerEXT,但这个函数不是直接导出的 API,而是通过 vkGetInstanceProcAddr 动态获取的函数指针。这反映了 Vulkan 的另一个设计特点:很多扩展功能(尤其是 WIS 相关的函数)需要通过函数指针调用,而不是直接链接到库中。

static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
    VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
    VkDebugUtilsMessageTypeFlagsEXT messageType,
    const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
    void* pUserData) {
    std::cerr << "validation layer: " << pCallbackData->pMessage << std::endl;
    return VK_FALSE;
}

三、物理设备枚举与队列族选择

创建 Instance 后,下一步是枚举系统中所有支持 Vulkan 的 GPU,并从中选择一个适合渲染任务的设备。vkEnumeratePhysicalDevices 返回 VkPhysicalDevice 数组,每个代表一块物理 GPU。

uint32_t deviceCount = 0;
vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
std::vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

大多数系统只有一个独立 GPU,但专业工作站或多显卡配置中可能存在多个设备。遍历这些设备时,需要检查三个关键条件:

  1. 图形队列族可用:必须存在至少一个支持 VK_QUEUE_GRAPHICS_BIT 的队列族。
  2. Swap Chain 扩展支持:物理设备必须支持 VK_KHR_swapchain 设备级扩展。
  3. Surface 呈现能力:目标队列族必须支持在已创建的 VkSurfaceKHR 上进行呈现操作,这通过 vkGetPhysicalDeviceSurfaceSupportKHR 验证。

这三个条件共同决定了设备可用性。以下是一个简化的设备适用性检查逻辑:

bool isDeviceSuitable(VkPhysicalDevice device) {
    QueueFamilyIndices indices = findQueueFamilies(device);
    bool extensionsSupported = checkDeviceExtensionSupport(device);
    bool swapChainAdequate = false;
    if (extensionsSupported) {
        SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device);
        swapChainAdequate = !swapChainSupport.formats.empty() 
                         && !swapChainSupport.presentModes.empty();
    }
    return indices.isComplete() && extensionsSupported && swapChainAdequate;
}

队列族(Queue Family)是 Vulkan 中一类非常重要的抽象。同一个物理设备可能有多个队列族,每个族代表一类功能能力。最常见的队列族类型包括:

  • Graphics Queue:执行图形渲染管线相关命令(顶点处理、光栅化、片段着色)。
  • Compute Queue:执行计算管线命令(Compute Shader)。
  • Transfer Queue:专用于资源传输操作(Buffer/Texture 的 CPU-GPU 拷贝)。
  • Sparse Binding Queue:用于稀疏资源的绑定操作。

对于三角形渲染,我们至少需要找到支持 Graphics 和 Present 两类操作的队列。在集成显卡的环境中,这两类队列往往属于同一个队列族(通常是家族索引 0)。而在独立显卡配置中,公路图队列(如 NVIDIA Optimus)可能要求两个不同的队列族协同工作。


四、逻辑设备与 Queue 创建

选定物理设备后,需要通过 vkCreateDevice 创建 逻辑设备(Logical Device)。逻辑设备是应用程序与物理设备交互的实际 API 句柄,类似于 OpenGL 中的 Context。一个物理设备可以被多个逻辑设备实例化,这在某些高级应用场景(如 GPU 虚拟化)中很有用。

创建逻辑设备时,核心参数是 VkDeviceQueueCreateInfo 数组,它声明了需要创建的队列及其数量。每个队列可以设置优先级(pQueuePriorities),取值范围 0.0 到 1.0,优先级仅在同队列族内部有效。

float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = graphicsFamilyIndex;
queueCreateInfo.queueCount = 1;
queueCreateInfo.pQueuePriorities = &queuePriority;

如果 Graphics 和 Present 的队列族不同,需要分别为它们创建队列信息,并通过 std::set 去重后统一传入 VkDeviceCreateInfo

逻辑设备创建还必须指定所需的功能特性(VkPhysicalDeviceFeatures)和设备级扩展。对于三角形渲染,至少需要启用 VK_KHR_swapchain 扩展。VkPhysicalDeviceFeatures 可以留空使用默认值,表示不需要任何特殊的可选硬件特性(如几何着色器、曲面细分着色器等)。

VkPhysicalDeviceFeatures deviceFeatures{};
VkDeviceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
createInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
createInfo.pQueueCreateInfos = queueCreateInfos.data();
createInfo.pEnabledFeatures = &deviceFeatures;
createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
createInfo.ppEnabledExtensionNames = deviceExtensions.data();

逻辑设备创建成功后,通过 vkGetDeviceQueue 获取实际的队列对象:

vkGetDeviceQueue(device, indices.graphicsFamily.value(), 0, &graphicsQueue);
vkGetDeviceQueue(device, indices.presentFamily.value(), 0, &presentQueue);

这个队列(VkQueue)是后续所有命令提交的操作目标。一个队列对象与一个 Command Pool 关联——Command Pool 从该队列所属的队列族中分配命令缓冲。


五、表面(Surface)创建与窗口系统集成

在现代图形 API 中,渲染结果最终需要显示到操作系统的窗口上。Vulkan 本身不负责窗口管理,它通过 窗口系统集成(Window System Integration, WIS) 将渲染输出桥接到窗口层。具体的实现方式是:创建 VkSurfaceKHR 对象,它封装了平台相关的窗口表面。

由于 VkSurfaceKHR 是平台相关的(Windows 使用 VK_KHR_win32_surface,Linux 使用 VK_KHR_xcb_surfaceVK_KHR_wayland_surface),跨平台开发通常借助 GLFW 或 SDL 等库来处理差异。

VkSurfaceKHR surface;
glfwCreateWindowSurface(instance, window, nullptr, &surface);

GLFW 内部会根据当前操作系统选择正确的平台扩展,并调用对应的 vkCreate*SurfaceKHR 函数。Surface 创建后,必须在前面提到的物理设备选择步骤中,验证目标设备是否能在该 Surface 上执行呈现操作:

VkBool32 presentSupport = false;
vkGetPhysicalDeviceSurfaceSupportKHR(physicalDevice, queueFamilyIndex, surface, &presentSupport);

如果 presentSupport 返回 VK_FALSE,则该队列族无法用于呈现,需要寻找其他队列族或换一个物理设备。

Surface 同时还影响着后续 Swap Chain 的配置。通过 vkGetPhysicalDeviceSurfaceCapabilitiesKHRvkGetPhysicalDeviceSurfaceFormatsKHRvkGetPhysicalDeviceSurfacePresentModesKHR 三个查询函数,可以获取目标设备在该 Surface 上的全部渲染能力参数。


六、交换链(Swap Chain)创建

Swap Chain 是 Vulkan 中唯一承担画面刷新与缓冲管理的对象。它本质上是一个图像队列,负责在 GPU 渲染完成后,将帧以正确的格式和呈现模式发送给 Surface。由于所有图像都由驱动预先分配,开发者不能直接用 vkCreateImage 来创建 Swap Chain 图像,而必须通过 vkGetSwapchainImagesKHR 获取驱动返回的图像句柄数组。

Swap Chain 的配置涉及多个关键参数,任何一个选择不当都可能导致渲染失败或画面异常。

图像数量(minImageCount)

Swap Chain 的图像数量直接对应双缓冲或三缓冲机制。选择 capabilities.minImageCount + 1(通常为 2)可以在避免画面撕裂的同时控制延迟。对于不含上限约束的设备(maxImageCount == 0),可以分配更多图像来构造 Mailbox 或三缓冲,但增加了内存占用。

表面格式(Surface Format)

格式由二维属性组成:format(像素数据布局,如 VK_FORMAT_B8G8R8A8_SRGB)和 colorSpace(色域定义,如 VK_COLOR_SPACE_SRGB_NONLINEAR_KHR)。B8G8R8A8_SRGBSRGB_NONLINEAR 是目前最通用的组合,几乎所有 GPU 都原生支持。

呈现模式(Present Mode)

Vulkan 定义了四种标准呈现模式:

呈现模式行为特征适用场景
VK_PRESENT_MODE_FIFO_KHR按提交队列先进先出,配合 V-Sync 时严格锁定显示器刷新率默认首选,无撕裂,低功耗
VK_PRESENT_MODE_FIFO_RELAXED_KHRFIFO 的宽松版本,允许在错过 V-Sync 周期时立即呈现,可能产生撕裂需要低延迟但可接受偶发撕裂
VK_PRESENT_MODE_MAILBOX_KHR单队列,每帧覆盖待呈现图像,丢弃过时的图像极低延迟,适合交互式应用
VK_PRESENT_MODE_IMMEDIATE_KHR不等待 V-Sync,直接呈现到屏幕无延迟,但有明显画面撕裂

工程实践中推荐优先选择 MAILBOX_KHR,若不可用则回退到 FIFO_KHR

图像尺寸(Image Extent)

vkGetPhysicalDeviceSurfaceCapabilitiesKHR 返回的 currentExtent 获取实际窗口尺寸。如果返回了特殊值 std::numeric_limits<uint32_t>::max(),说明平台允许自定义尺寸,此时可以从 GLFW 获取窗口大小并通过 std::clamp 限制在 minImageExtentmaxImageExtent 之间。

图像共享模式(Sharing Mode)

当 Graphics Queue 和 Present Queue 属于不同的队列族时,交换链图像需要在两个队列族之间共享。此时应选择 VK_SHARING_MODE_CONCURRENT 模式并传入两个队列族索引数组。如果两者相同,则使用 VK_SHARING_MODE_EXCLUSIVE 以获得更优的驱动性能。

VkSwapchainCreateInfoKHR createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
createInfo.surface = surface;
createInfo.minImageCount = imageCount;
createInfo.imageFormat = surfaceFormat.format;
createInfo.imageColorSpace = surfaceFormat.colorSpace;
createInfo.imageExtent = extent;
createInfo.imageArrayLayers = 1;
createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
createInfo.preTransform = capabilities.currentTransform;
createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
createInfo.presentMode = presentMode;
createInfo.clipped = VK_TRUE;
createInfo.oldSwapchain = VK_NULL_HANDLE;

clipped = VK_TRUE 允许驱动裁剪不可见像素(如窗口被遮挡时),这是一个重要的性能优化。oldSwapchain 在窗口尺寸变化时需要传入旧 Swap Chain 以允许图像复用,但在初次创建时设为 VK_NULL_HANDLE

Swap Chain 创建成功后,通过两次调用 vkGetSwapchainImagesKHR(第一次查询数量、第二次获取句柄)提取图像数组。这些图像是不可直接使用的原始句柄,必须为每个图像创建 VkImageView,才能在后续渲染流程中引用。


七、渲染通道(Render Pass)与帧缓冲(Framebuffer)

Render Pass 和 Framebuffer 是 Vulkan 渲染控制的核心概念。Render Pass 定义了一个完整的渲染流程中,所有附件(Attachments)的类型、加载/存储操作以及 Subpass 之间的依赖关系。Framebuffer 则是 Render Pass 的实例化——它将具体的 VkImageView 绑定到 Render Pass 定义的附件槽位上。

这种分离设计的优势在于:同一套 Render Pass 定义可以复用于多个不同尺寸的 Framebuffer,实现了渲染流程定义与渲染目标数据的解耦。

对于三角形渲染,Render Pass 只需要一个颜色附件。通过 VkAttachmentDescription 描述其属性:

VkAttachmentDescription colorAttachment{};
colorAttachment.format = swapChainImageFormat;
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;           // 关闭 MSAA
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;      // 每帧开始时清理背景色
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;    // 保留渲染结果用于呈现
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;

loadOpCLEAR 意味着在 Render Pass 开始时,驱动会将颜色附件清空为指定背景色。如果不执行 CLEAR,附件将包含上一帧的残留像素,导致画面紊乱。initialLayout 设为 UNDEFINED 表示我们不需要保留附件的先前内容;finalLayout 设为 PRESENT_SRC_KHR 表示渲染完成后图像应转换为适合 Swap Chain 呈现的 Layout。

Subpass Dependency 控制不同执行阶段之间的资源同步。对于单 Subpass 渲染,至少需要声明从外部到 Subpass,以及从 Subpass 到外部的执行依赖:

VkSubpassDependency dependency{};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;  // 隐式的前一个执行阶段
dependency.dstSubpass = 0;                    // 当前 Subpass 索引
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.srcAccessMask = 0;
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;

这条依赖声明:在进入当前 Subpass 的颜色附件输出阶段之前,必须等待前一个阶段的颜色附件输出阶段完成。这保证了 Swap Chain 图像在被读取之前,驱动已经完成了所有先前操作。

创建 Framebuffer 时,需要为 Swap Chain 的每个图像分别创建一个实例:

for (size_t i = 0; i < swapChainImageViews.size(); i++) {
    VkImageView attachments[] = { swapChainImageViews[i] };
    VkFramebufferCreateInfo framebufferInfo{};
    framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
    framebufferInfo.renderPass = renderPass;
    framebufferInfo.attachmentCount = 1;
    framebufferInfo.pAttachments = attachments;
    framebufferInfo.width = swapChainExtent.width;
    framebufferInfo.height = swapChainExtent.height;
    framebufferInfo.layers = 1;
    vkCreateFramebuffer(device, &framebufferInfo, nullptr, &swapChainFramebuffers[i]);
}

八、渲染管线(Graphics Pipeline)

Graphics Pipeline 是 Vulkan 中最复杂的单个对象。它封装了从顶点输入到像素输出的整个 GPU 处理流水线。创建 Graphics Pipeline 需要一次性传递 10 余个状态结构体指针,涵盖固定功能阶段和可编程着色器阶段。

顶点输入阶段(Vertex Input)

VkPipelineVertexInputStateCreateInfo 定义了顶点数据的绑定方式(Binding Description)和属性解析规则(Attribute Description)。在本例中,三角形顶点被硬编码在顶点着色器中,因此这两类描述数量均为零。

图元装配(Input Assembly)

topology 字段声明图元类型。对于三角形,使用 VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST,表示每三个独立顶点构成一个三角形。其他常用拓扑包括 TRIANGLE_STRIP(带状拼接,节省顶点数)和 TRIANGLE_FAN(扇形拼接)。

VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
inputAssembly.primitiveRestartEnable = VK_FALSE;

光栅化阶段(Rasterization)

光栅化阶段将上游处理的图元转换为片元(Fragment),是最能体现 GPU 特性的阶段之一。核心配置包括:

  • polygonModeVK_POLYGON_MODE_FILL 为填充模式,LINE 为线框模式,POINT 为点云模式。
  • cullMode:剔除背面可减少约一半的片段着色器执行次数。但需注意顶点环绕方向,frontFace 必须与实际模型数据一致。
  • depthBiasEnable:在阴影映射中用于解决 Z-Fighting,本场景保持关闭。
VkPipelineRasterizationStateCreateInfo rasterizer{};
rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizer.depthClampEnable = VK_FALSE;
rasterizer.rasterizerDiscardEnable = VK_FALSE;
rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
rasterizer.lineWidth = 1.0f;
rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;
rasterizer.depthBiasEnable = VK_FALSE;

多重采样(Multisample)

VK_SAMPLE_COUNT_1_BIT 表示关闭抗锯齿。开启 MSAA 需同时修改颜色附件的 samples 属性、Render Pass 附件描述以及在 VkPipelineMultisampleStateCreateInfo 中启用 sampleShading

颜色混合(Color Blend)

VkPipelineColorBlendAttachmentState 定义单个颜色附件的混合规则,blendEnable = VK_FALSE 表示直接覆盖颜色缓冲,不执行 Alpha 混合。

动态状态(Dynamic State)

Pipeline 创建后大部分状态变为不可变的(immutable),但视口(Viewport)和裁剪矩形(Scissor)是 Vulkan 允许在录制命令时动态修改的状态。通过 VK_DYNAMIC_STATE_VIEWPORTVK_DYNAMIC_STATE_SCISSOR 声明这部分动态性,随后在 vkCmdSetViewportvkCmdSetScissor 中设置实际值。


九、着色器阶段(顶点/片段)与 SPIR-V 加载

Vulkan 不使用 GLSL 源代码作为着色器输入,而是要求开发者提供编译后的 SPIR-V 二进制字节码。SPIR-V 是一种中间表示语言,由 Khronos 定义,目标是作为所有高级着色语言(GLSL、HLSL、OpenCL C)的统一后端。

在常规开发中,开发者使用 glslangValidatorglslc 将 GLSL 源代码离线编译为 .spv 二进制文件,然后在运行时通过 std::ifstream 读取这些文件并创建 VkShaderModule。这种方式虽然灵活,但增加了部署复杂性——程序需要随附着色器文件或将其打包为资源。

为了使本文的示例程序真正做到“单文件可编译运行”,着色器字节码被直接嵌入为 C++ uint32_t 数组。下面是两个极简着色器的 SPIR-V 字节码及其语义解读。

顶点着色器

该顶点着色器无视输入顶点数据,直接为每个片元输出固定的裁剪空间坐标。gl_Position = vec4(1.0, 1.0, 0.0, 1.0) 表示将三角形的一个顶点放置在裁剪空间的右上角。配合 vkCmdDraw(commandBuffer, 3, 1, 0, 0)(绘制 3 个顶点),GPU 将生成覆盖右上象限的三角形。

片段着色器

该片段着色器同样不考虑任何输入,直接为每个片元输出固定颜色 vec4(1.0, 0.0, 0.0, 1.0),即完全不透明的红色。

从技术上讲,SPIR-V 字节码包含以下关键结构:文件头(Magic、版本号、生成器 ID、ID 绑定上限)、能力声明(OpCapability Shader)、扩展导入(GLSL.std.450)、内存模型和入口点(OpEntryPoint Vertex/Fragment)、执行模式(OriginUpperLeft 表示屏幕坐标原点在左上角)、类型定义(OpTypeFloat 32OpTypeVector 构造 vec4)、变量声明与修饰符(BuiltIn: PositionLocation: 0)、常数定义(IEEE-754 单精度编码如 0x3F800000 对应 1.0f)以及函数体和存储指令。

加载 SPIR-V 到 VkShaderModule 的过程非常简单:

VkShaderModuleCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = code.size() * sizeof(uint32_t);
createInfo.pCode = code.data();

VkShaderModule shaderModule;
vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule);

Driver 接收到 SPIR-V 后,将其翻译为目标 GPU 的原生指令集。Vulkan 不要求驱动支持 GLSL 解析器,因此 SPIR-V 是唯一的标准着色器输入格式。这种设计带来的好处是:着色器编译错误在离线阶段就能发现,而不是依赖各驱动的 GLSL 编译器( notoriously inconsistent )。


十、命令缓冲录制与帧循环提交

命令缓冲是 Vulkan GPU 工作的最核心载体。与 OpenGL 的立即模式不同,Vulkan 要求开发者将所有 GPU 操作预先录制到命令缓冲中,然后一次性提交到队列执行。这种批处理机制极大地减少了 CPU-GPU 通信开销。

命令池与命令缓冲分配

命令缓冲由 VkCommandPool 分配。每个 Command Pool 关联一个特定的队列族索引。推荐的做法是为每帧分配独立的 Command Pool,并在帧末重置整个 Pool,这比逐个重置命令缓冲高效得多。

VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
poolInfo.queueFamilyIndex = queueFamilyIndices.graphicsFamily.value();
vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool);

命令缓冲录制

在每一帧的绘制过程中,命令缓冲需要被重新录制(或复用)。录制内容包括:

  1. vkBeginCommandBuffer 开启录制。
  2. vkCmdBeginRenderPass 进入 Render Pass 实例,绑定对应的 Framebuffer,设置 Clear Color。
  3. vkCmdBindPipeline 绑定 Graphics Pipeline 状态。
  4. vkCmdSetViewport / vkCmdSetScissor 设置动态状态。
  5. vkCmdDraw 发出绘制调用:3 个顶点、1 个实例、从索引 0 开始。
  6. vkCmdEndRenderPass 结束当前 Render Pass。
  7. vkEndCommandBuffer 完成录制。
vkCmdBeginRenderPass(cmd, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
vkCmdSetViewport(cmd, 0, 1, &viewport);
vkCmdSetScissor(cmd, 0, 1, &scissor);
vkCmdDraw(cmd, 3, 1, 0, 0);
vkCmdEndRenderPass(cmd);

vkCmdDraw 的四个参数含义是:顶点数(3)、实例数(1,非 Instanced 渲染时为 1)、首个顶点索引、首个实例索引。

帧循环中的同步与提交

绘制循环遵循经典的三阶段同步模式:

  1. 获取图像vkAcquireNextImageKHR 从 Swap Chain 请求一帧可用图像。该调用异步执行,完成后发出 imageAvailableSemaphore 信号。
  2. 提交渲染命令vkQueueSubmit 将录制好的命令缓冲推入队列。通过 pWaitSemaphores 等待 imageAvailableSemaphore,确保只有获取到图像后才开始渲染;通过 pSignalSemaphores 设置 renderFinishedSemaphore,表示渲染完成后驱动可以进入下一阶段。
  3. 呈现图像vkQueuePresentKHR 请求窗口系统将渲染完成的图像显示到屏幕上。通过 pWaitSemaphores 等待 renderFinishedSemaphore,确保只有在 GPU 完成渲染后才开始呈现操作。

Fence 用于 CPU 侧的显式等待。vkWaitForFences 阻塞当前线程直到上一帧的 GPU 工作完成,然后 vkResetFences 重置栅栏状态以准备下一帧使用。Fence 初始状态通过 VK_FENCE_CREATE_SIGNALED_BIT 设为已触发,这样第一帧的等待调用可以立即返回,避免死锁。

vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX);
vkResetFences(device, 1, &inFlightFence);
vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
// ... 录制命令缓冲 ...
vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence);
vkQueuePresentKHR(presentQueue, &presentInfo);

UINT64_MAX 作为超时参数表示无限等待。在真实的游戏引擎中,这一等待不应超过一帧的目标时间(例如 16.6ms 对应 60 FPS),否则需要通过超时处理检测性能瓶颈或 GPU 挂起。


十一、完整可编译代码

以下是一个完整的、零外部文件依赖的 Vulkan Hello Triangle 程序。着色器字节码以 std::vector<uint32_t> 形式直接内嵌,因此编译后无需任何 .spv 文件即可运行。程序使用 GLFW3 创建窗口,使用 C++17 标准。

编译命令(以 CMake 为例,或直接使用命令行):

g++ -std=c++17 -O2 main.cpp -lglfw -lvulkan -o triangle

或 macOS(使用 MoltenVK):

clang++ -std=c++17 -O2 main.cpp -lglfw -framework MoltenVK -o triangle
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>

#include <iostream>
#include <stdexcept>
#include <algorithm>
#include <vector>
#include <cstring>
#include <cstdlib>
#include <cstdint>
#include <limits>
#include <optional>
#include <set>

const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;

const int MAX_FRAMES_IN_FLIGHT = 2;

const std::vector<const char*> validationLayers = {
    "VK_LAYER_KHRONOS_validation"
};

const std::vector<const char*> deviceExtensions = {
    VK_KHR_SWAPCHAIN_EXTENSION_NAME
};

#ifdef NDEBUG
const bool enableValidationLayers = false;
#else
const bool enableValidationLayers = true;
#endif

VkResult CreateDebugUtilsMessengerEXT(VkInstance instance, const VkDebugUtilsMessengerCreateInfoEXT* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkDebugUtilsMessengerEXT* pDebugMessenger) {
    auto func = (PFN_vkCreateDebugUtilsMessengerEXT) vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");
    if (func != nullptr) {
        return func(instance, pCreateInfo, pAllocator, pDebugMessenger);
    } else {
        return VK_ERROR_EXTENSION_NOT_PRESENT;
    }
}

void DestroyDebugUtilsMessengerEXT(VkInstance instance, VkDebugUtilsMessengerEXT debugMessenger, const VkAllocationCallbacks* pAllocator) {
    auto func = (PFN_vkDestroyDebugUtilsMessengerEXT) vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
    if (func != nullptr) {
        func(instance, debugMessenger, pAllocator);
    }
}

struct QueueFamilyIndices {
    std::optional<uint32_t> graphicsFamily;
    std::optional<uint32_t> presentFamily;

    bool isComplete() {
        return graphicsFamily.has_value() && presentFamily.has_value();
    }
};

struct SwapChainSupportDetails {
    VkSurfaceCapabilitiesKHR capabilities;
    std::vector<VkSurfaceFormatKHR> formats;
    std::vector<VkPresentModeKHR> presentModes;
};

class HelloTriangleApplication {
public:
    void run() {
        initWindow();
        initVulkan();
        mainLoop();
        cleanup();
    }

private:
    GLFWwindow* window;

    VkInstance instance;
    VkDebugUtilsMessengerEXT debugMessenger;
    VkSurfaceKHR surface;

    VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
    VkDevice device;

    VkQueue graphicsQueue;
    VkQueue presentQueue;

    VkSwapchainKHR swapChain;
    std::vector<VkImage> swapChainImages;
    VkFormat swapChainImageFormat;
    VkExtent2D swapChainExtent;
    std::vector<VkImageView> swapChainImageViews;
    std::vector<VkFramebuffer> swapChainFramebuffers;

    VkRenderPass renderPass;
    VkPipelineLayout pipelineLayout;
    VkPipeline graphicsPipeline;

    VkCommandPool commandPool;
    VkCommandBuffer commandBuffer;

    VkSemaphore imageAvailableSemaphore;
    VkSemaphore renderFinishedSemaphore;
    VkFence inFlightFence;

    void initWindow() {
        glfwInit();

        glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
        glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);

        window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
    }

    void initVulkan() {
        createInstance();
        setupDebugMessenger();
        createSurface();
        pickPhysicalDevice();
        createLogicalDevice();
        createSwapChain();
        createImageViews();
        createRenderPass();
        createGraphicsPipeline();
        createFramebuffers();
        createCommandPool();
        createCommandBuffer();
        createSyncObjects();
    }

    void mainLoop() {
        while (!glfwWindowShouldClose(window)) {
            glfwPollEvents();
            drawFrame();
        }

        vkDeviceWaitIdle(device);
    }

    void cleanup() {
        vkDestroySemaphore(device, renderFinishedSemaphore, nullptr);
        vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);
        vkDestroyFence(device, inFlightFence, nullptr);

        vkDestroyCommandPool(device, commandPool, nullptr);

        for (auto framebuffer : swapChainFramebuffers) {
            vkDestroyFramebuffer(device, framebuffer, nullptr);
        }

        vkDestroyPipeline(device, graphicsPipeline, nullptr);
        vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
        vkDestroyRenderPass(device, renderPass, nullptr);

        for (auto imageView : swapChainImageViews) {
            vkDestroyImageView(device, imageView, nullptr);
        }

        vkDestroySwapchainKHR(device, swapChain, nullptr);
        vkDestroyDevice(device, nullptr);

        if (enableValidationLayers) {
            DestroyDebugUtilsMessengerEXT(instance, debugMessenger, nullptr);
        }

        vkDestroySurfaceKHR(instance, surface, nullptr);
        vkDestroyInstance(instance, nullptr);

        glfwDestroyWindow(window);

        glfwTerminate();
    }

    void createInstance() {
        if (enableValidationLayers && !checkValidationLayerSupport()) {
            throw std::runtime_error("validation layers requested, but not available!");
        }

        VkApplicationInfo appInfo{};
        appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
        appInfo.pApplicationName = "Hello Triangle";
        appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
        appInfo.pEngineName = "No Engine";
        appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
        appInfo.apiVersion = VK_API_VERSION_1_0;

        VkInstanceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
        createInfo.pApplicationInfo = &appInfo;

        auto extensions = getRequiredExtensions();
        createInfo.enabledExtensionCount = static_cast<uint32_t>(extensions.size());
        createInfo.ppEnabledExtensionNames = extensions.data();

        VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
        if (enableValidationLayers) {
            createInfo.enabledLayerCount = static_cast<uint32_t>(validationLayers.size());
            createInfo.ppEnabledLayerNames = validationLayers.data();

            populateDebugMessengerCreateInfo(debugCreateInfo);
            createInfo.pNext = (VkDebugUtilsMessengerCreateInfoEXT*) &debugCreateInfo;
        } else {
            createInfo.enabledLayerCount = 0;

            createInfo.pNext = nullptr;
        }

        if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) {
            throw std::runtime_error("failed to create instance!");
        }
    }

    void populateDebugMessengerCreateInfo(VkDebugUtilsMessengerCreateInfoEXT& createInfo) {
        createInfo = {};
        createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
        createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
        createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
        createInfo.pfnUserCallback = debugCallback;
    }

    void setupDebugMessenger() {
        if (!enableValidationLayers) return;

        VkDebugUtilsMessengerCreateInfoEXT createInfo;
        populateDebugMessengerCreateInfo(createInfo);

        if (CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr, &debugMessenger) != VK_SUCCESS) {
            throw std::runtime_error("failed to set up debug messenger!");
        }
    }

    void createSurface() {
        if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS) {
            throw std::runtime_error("failed to create window surface!");
        }
    }

    void pickPhysicalDevice() {
        uint32_t deviceCount = 0;
        vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);

        if (deviceCount == 0) {
            throw std::runtime_error("failed to find GPUs with Vulkan support!");
        }

        std::vector<VkPhysicalDevice> devices(deviceCount);
        vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

        for (const auto& device : devices) {
            if (isDeviceSuitable(device)) {
                physicalDevice = device;
                break;
            }
        }

        if (physicalDevice == VK_NULL_HANDLE) {
            throw std::runtime_error("failed to find a suitable GPU!");
        }
    }

    void createLogicalDevice() {
        QueueFamilyIndices indices = findQueueFamilies(physicalDevice);

        std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;
        std::set<uint32_t> uniqueQueueFamilies = {indices.graphicsFamily.value(), indices.presentFamily.value()};

        float queuePriority = 1.0f;
        for (uint32_t queueFamily : uniqueQueueFamilies) {
            VkDeviceQueueCreateInfo queueCreateInfo{};
            queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
            queueCreateInfo.queueFamilyIndex = queueFamily;
            queueCreateInfo.queueCount = 1;
            queueCreateInfo.pQueuePriorities = &queuePriority;
            queueCreateInfos.push_back(queueCreateInfo);
        }

        VkPhysicalDeviceFeatures deviceFeatures{};

        VkDeviceCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;

        createInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
        createInfo.pQueueCreateInfos = queueCreateInfos.data();

        createInfo.pEnabledFeatures = &deviceFeatures;

        createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
        createInfo.ppEnabledExtensionNames = deviceExtensions.data();

        if (enableValidationLayers) {
            createInfo.enabledLayerCount = static_cast<uint32_t>(validationLayers.size());
            createInfo.ppEnabledLayerNames = validationLayers.data();
        } else {
            createInfo.enabledLayerCount = 0;
        }

        if (vkCreateDevice(physicalDevice, &createInfo, nullptr, &device) != VK_SUCCESS) {
            throw std::runtime_error("failed to create logical device!");
        }

        vkGetDeviceQueue(device, indices.graphicsFamily.value(), 0, &graphicsQueue);
        vkGetDeviceQueue(device, indices.presentFamily.value(), 0, &presentQueue);
    }

    void createSwapChain() {
        SwapChainSupportDetails swapChainSupport = querySwapChainSupport(physicalDevice);

        VkSurfaceFormatKHR surfaceFormat = chooseSwapSurfaceFormat(swapChainSupport.formats);
        VkPresentModeKHR presentMode = chooseSwapPresentMode(swapChainSupport.presentModes);
        VkExtent2D extent = chooseSwapExtent(swapChainSupport.capabilities);

        uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;
        if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {
            imageCount = swapChainSupport.capabilities.maxImageCount;
        }

        VkSwapchainCreateInfoKHR createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
        createInfo.surface = surface;

        createInfo.minImageCount = imageCount;
        createInfo.imageFormat = surfaceFormat.format;
        createInfo.imageColorSpace = surfaceFormat.colorSpace;
        createInfo.imageExtent = extent;
        createInfo.imageArrayLayers = 1;
        createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

        QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
        uint32_t queueFamilyIndices[] = {indices.graphicsFamily.value(), indices.presentFamily.value()};

        if (indices.graphicsFamily != indices.presentFamily) {
            createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;
            createInfo.queueFamilyIndexCount = 2;
            createInfo.pQueueFamilyIndices = queueFamilyIndices;
        } else {
            createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
        }

        createInfo.preTransform = swapChainSupport.capabilities.currentTransform;
        createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
        createInfo.presentMode = presentMode;
        createInfo.clipped = VK_TRUE;

        createInfo.oldSwapchain = VK_NULL_HANDLE;

        if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain) != VK_SUCCESS) {
            throw std::runtime_error("failed to create swap chain!");
        }

        vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr);
        swapChainImages.resize(imageCount);
        vkGetSwapchainImagesKHR(device, swapChain, &imageCount, swapChainImages.data());

        swapChainImageFormat = surfaceFormat.format;
        swapChainExtent = extent;
    }

    void createImageViews() {
        swapChainImageViews.resize(swapChainImages.size());

        for (size_t i = 0; i < swapChainImages.size(); i++) {
            VkImageViewCreateInfo createInfo{};
            createInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
            createInfo.image = swapChainImages[i];
            createInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
            createInfo.format = swapChainImageFormat;
            createInfo.components.r = VK_COMPONENT_SWIZZLE_IDENTITY;
            createInfo.components.g = VK_COMPONENT_SWIZZLE_IDENTITY;
            createInfo.components.b = VK_COMPONENT_SWIZZLE_IDENTITY;
            createInfo.components.a = VK_COMPONENT_SWIZZLE_IDENTITY;
            createInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
            createInfo.subresourceRange.baseMipLevel = 0;
            createInfo.subresourceRange.levelCount = 1;
            createInfo.subresourceRange.baseArrayLayer = 0;
            createInfo.subresourceRange.layerCount = 1;

            if (vkCreateImageView(device, &createInfo, nullptr, &swapChainImageViews[i]) != VK_SUCCESS) {
                throw std::runtime_error("failed to create image views!");
            }
        }
    }

    void createRenderPass() {
        VkAttachmentDescription colorAttachment{};
        colorAttachment.format = swapChainImageFormat;
        colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
        colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
        colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
        colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
        colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
        colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
        colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;

        VkAttachmentReference colorAttachmentRef{};
        colorAttachmentRef.attachment = 0;
        colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;

        VkSubpassDescription subpass{};
        subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
        subpass.colorAttachmentCount = 1;
        subpass.pColorAttachments = &colorAttachmentRef;

        VkSubpassDependency dependency{};
        dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
        dependency.dstSubpass = 0;
        dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
        dependency.srcAccessMask = 0;
        dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
        dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;

        VkRenderPassCreateInfo renderPassInfo{};
        renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
        renderPassInfo.attachmentCount = 1;
        renderPassInfo.pAttachments = &colorAttachment;
        renderPassInfo.subpassCount = 1;
        renderPassInfo.pSubpasses = &subpass;
        renderPassInfo.dependencyCount = 1;
        renderPassInfo.pDependencies = &dependency;

        if (vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) {
            throw std::runtime_error("failed to create render pass!");
        }
    }

    // SPIR-V bytecode for hardcoded vertex shader (gl_Position = vec4(1,1,0,1))
    const std::vector<uint32_t> vertSPV = {
        0x07230203, 0x00010000, 0x00000000, 0x0000000C,
        0x00000000, 0x00020011, 0x00000001, 0x0003000B,
        0x0006000B, 0x4C534C47, 0x6474732E, 0x3035342E,
        0x00000000, 0x0003000E, 0x00000000, 0x00000001,
        0x0005000F, 0x00000000, 0x0006000F, 0x6E69616D,
        0x00000000, 0x00000009, 0x00030010, 0x00000004,
        0x00000007, 0x00040047, 0x00000009, 0x0000000B,
        0x00000000, 0x00020013, 0x00000002, 0x00030021,
        0x00000003, 0x00000002, 0x00030016, 0x00000006,
        0x00000020, 0x00040017, 0x00000007, 0x00000006,
        0x00000004, 0x00040020, 0x00000008, 0x00000003,
        0x00000007, 0x0004003B, 0x00000009, 0x00000008,
        0x00000003, 0x0004002B, 0x0000000A, 0x00000006,
        0x3F800000, 0x0004002B, 0x0000000B, 0x00000006,
        0x00000000, 0x0007002C, 0x0000000C, 0x00000007,
        0x0000000A, 0x0000000A, 0x0000000B, 0x0000000A,
        0x00050036, 0x00000004, 0x00000002, 0x00000000,
        0x00000003, 0x000200F8, 0x00000005, 0x0003003E,
        0x00000009, 0x0000000C, 0x000100FD, 0x00010038,
    };

    // SPIR-V bytecode for hardcoded fragment shader (output = vec4(1,0,0,1))
    const std::vector<uint32_t> fragSPV = {
        0x07230203, 0x00010000, 0x00000000, 0x0000000C,
        0x00000000, 0x00020011, 0x00000001, 0x0003000B,
        0x0006000B, 0x4C534C47, 0x6474732E, 0x3035342E,
        0x00000000, 0x0003000E, 0x00000000, 0x00000001,
        0x0005000F, 0x00000004, 0x0006000F, 0x6E69616D,
        0x00000000, 0x00000009, 0x00030010, 0x00000004,
        0x00000007, 0x00040047, 0x00000009, 0x0000001E,
        0x00000000, 0x00020013, 0x00000002, 0x00030021,
        0x00000003, 0x00000002, 0x00030016, 0x00000006,
        0x00000020, 0x00040017, 0x00000007, 0x00000006,
        0x00000004, 0x00040020, 0x00000008, 0x00000003,
        0x00000007, 0x0004003B, 0x00000009, 0x00000008,
        0x00000003, 0x0004002B, 0x0000000A, 0x00000006,
        0x3F800000, 0x0004002B, 0x0000000B, 0x00000006,
        0x00000000, 0x0007002C, 0x0000000C, 0x00000007,
        0x0000000A, 0x0000000B, 0x0000000B, 0x0000000A,
        0x00050036, 0x00000004, 0x00000002, 0x00000000,
        0x00000003, 0x000200F8, 0x00000005, 0x0003003E,
        0x00000009, 0x0000000C, 0x000100FD, 0x00010038,
    };

    void createGraphicsPipeline() {
        VkShaderModule vertShaderModule = createShaderModule(vertSPV);
        VkShaderModule fragShaderModule = createShaderModule(fragSPV);

        VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
        vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
        vertShaderStageInfo.module = vertShaderModule;
        vertShaderStageInfo.pName = "main";

        VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
        fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
        fragShaderStageInfo.module = fragShaderModule;
        fragShaderStageInfo.pName = "main";

        VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

        VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
        vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
        vertexInputInfo.vertexBindingDescriptionCount = 0;
        vertexInputInfo.vertexAttributeDescriptionCount = 0;

        VkPipelineInputAssemblyStateCreateInfo inputAssembly{};
        inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
        inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
        inputAssembly.primitiveRestartEnable = VK_FALSE;

        VkPipelineViewportStateCreateInfo viewportState{};
        viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
        viewportState.viewportCount = 1;
        viewportState.scissorCount = 1;

        VkPipelineRasterizationStateCreateInfo rasterizer{};
        rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
        rasterizer.depthClampEnable = VK_FALSE;
        rasterizer.rasterizerDiscardEnable = VK_FALSE;
        rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
        rasterizer.lineWidth = 1.0f;
        rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
        rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;
        rasterizer.depthBiasEnable = VK_FALSE;

        VkPipelineMultisampleStateCreateInfo multisampling{};
        multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
        multisampling.sampleShadingEnable = VK_FALSE;
        multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

        VkPipelineColorBlendAttachmentState colorBlendAttachment{};
        colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
        colorBlendAttachment.blendEnable = VK_FALSE;

        VkPipelineColorBlendStateCreateInfo colorBlending{};
        colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
        colorBlending.logicOpEnable = VK_FALSE;
        colorBlending.logicOp = VK_LOGIC_OP_COPY;
        colorBlending.attachmentCount = 1;
        colorBlending.pAttachments = &colorBlendAttachment;
        colorBlending.blendConstants[0] = 0.0f;
        colorBlending.blendConstants[1] = 0.0f;
        colorBlending.blendConstants[2] = 0.0f;
        colorBlending.blendConstants[3] = 0.0f;

        std::vector<VkDynamicState> dynamicStates = {
            VK_DYNAMIC_STATE_VIEWPORT,
            VK_DYNAMIC_STATE_SCISSOR
        };
        VkPipelineDynamicStateCreateInfo dynamicState{};
        dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
        dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size());
        dynamicState.pDynamicStates = dynamicStates.data();

        VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
        pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
        pipelineLayoutInfo.setLayoutCount = 0;
        pipelineLayoutInfo.pushConstantRangeCount = 0;

        if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) {
            throw std::runtime_error("failed to create pipeline layout!");
        }

        VkGraphicsPipelineCreateInfo pipelineInfo{};
        pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
        pipelineInfo.stageCount = 2;
        pipelineInfo.pStages = shaderStages;
        pipelineInfo.pVertexInputState = &vertexInputInfo;
        pipelineInfo.pInputAssemblyState = &inputAssembly;
        pipelineInfo.pViewportState = &viewportState;
        pipelineInfo.pRasterizationState = &rasterizer;
        pipelineInfo.pMultisampleState = &multisampling;
        pipelineInfo.pColorBlendState = &colorBlending;
        pipelineInfo.pDynamicState = &dynamicState;
        pipelineInfo.layout = pipelineLayout;
        pipelineInfo.renderPass = renderPass;
        pipelineInfo.subpass = 0;
        pipelineInfo.basePipelineHandle = VK_NULL_HANDLE;

        if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &graphicsPipeline) != VK_SUCCESS) {
            throw std::runtime_error("failed to create graphics pipeline!");
        }

        vkDestroyShaderModule(device, fragShaderModule, nullptr);
        vkDestroyShaderModule(device, vertShaderModule, nullptr);
    }

    void createFramebuffers() {
        swapChainFramebuffers.resize(swapChainImageViews.size());

        for (size_t i = 0; i < swapChainImageViews.size(); i++) {
            VkImageView attachments[] = {
                swapChainImageViews[i]
            };

            VkFramebufferCreateInfo framebufferInfo{};
            framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
            framebufferInfo.renderPass = renderPass;
            framebufferInfo.attachmentCount = 1;
            framebufferInfo.pAttachments = attachments;
            framebufferInfo.width = swapChainExtent.width;
            framebufferInfo.height = swapChainExtent.height;
            framebufferInfo.layers = 1;

            if (vkCreateFramebuffer(device, &framebufferInfo, nullptr, &swapChainFramebuffers[i]) != VK_SUCCESS) {
                throw std::runtime_error("failed to create framebuffer!");
            }
        }
    }

    void createCommandPool() {
        QueueFamilyIndices queueFamilyIndices = findQueueFamilies(physicalDevice);

        VkCommandPoolCreateInfo poolInfo{};
        poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
        poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
        poolInfo.queueFamilyIndex = queueFamilyIndices.graphicsFamily.value();

        if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
            throw std::runtime_error("failed to create command pool!");
        }
    }

    void createCommandBuffer() {
        VkCommandBufferAllocateInfo allocInfo{};
        allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
        allocInfo.commandPool = commandPool;
        allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
        allocInfo.commandBufferCount = 1;

        if (vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
            throw std::runtime_error("failed to allocate command buffers!");
        }
    }

    void recordCommandBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex) {
        VkCommandBufferBeginInfo beginInfo{};
        beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

        if (vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) {
            throw std::runtime_error("failed to begin recording command buffer!");
        }

        VkRenderPassBeginInfo renderPassInfo{};
        renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
        renderPassInfo.renderPass = renderPass;
        renderPassInfo.framebuffer = swapChainFramebuffers[imageIndex];
        renderPassInfo.renderArea.offset = {0, 0};
        renderPassInfo.renderArea.extent = swapChainExtent;

        VkClearValue clearColor = {{{0.0f, 0.0f, 0.0f, 1.0f}}};
        renderPassInfo.clearValueCount = 1;
        renderPassInfo.pClearValues = &clearColor;

        vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);

        vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

        VkViewport viewport{};
        viewport.x = 0.0f;
        viewport.y = 0.0f;
        viewport.width = static_cast<float>(swapChainExtent.width);
        viewport.height = static_cast<float>(swapChainExtent.height);
        viewport.minDepth = 0.0f;
        viewport.maxDepth = 1.0f;
        vkCmdSetViewport(commandBuffer, 0, 1, &viewport);

        VkRect2D scissor{};
        scissor.offset = {0, 0};
        scissor.extent = swapChainExtent;
        vkCmdSetScissor(commandBuffer, 0, 1, &scissor);

        vkCmdDraw(commandBuffer, 3, 1, 0, 0);

        vkCmdEndRenderPass(commandBuffer);

        if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) {
            throw std::runtime_error("failed to record command buffer!");
        }
    }

    void createSyncObjects() {
        VkSemaphoreCreateInfo semaphoreInfo{};
        semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

        VkFenceCreateInfo fenceInfo{};
        fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
        fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;

        if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore) != VK_SUCCESS ||
            vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore) != VK_SUCCESS ||
            vkCreateFence(device, &fenceInfo, nullptr, &inFlightFence) != VK_SUCCESS) {
            throw std::runtime_error("failed to create synchronization objects for a frame!");
        }

    }

    void drawFrame() {
        vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX);
        vkResetFences(device, 1, &inFlightFence);

        uint32_t imageIndex;
        vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);

        vkResetCommandBuffer(commandBuffer, /*VkCommandBufferResetFlagBits*/ 0);
        recordCommandBuffer(commandBuffer, imageIndex);

        VkSubmitInfo submitInfo{};
        submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;

        VkSemaphore waitSemaphores[] = {imageAvailableSemaphore};
        VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
        submitInfo.waitSemaphoreCount = 1;
        submitInfo.pWaitSemaphores = waitSemaphores;
        submitInfo.pWaitDstStageMask = waitStages;

        submitInfo.commandBufferCount = 1;
        submitInfo.pCommandBuffers = &commandBuffer;

        VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
        submitInfo.signalSemaphoreCount = 1;
        submitInfo.pSignalSemaphores = signalSemaphores;

        if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence) != VK_SUCCESS) {
            throw std::runtime_error("failed to submit draw command buffer!");
        }

        VkPresentInfoKHR presentInfo{};
        presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;

        presentInfo.waitSemaphoreCount = 1;
        presentInfo.pWaitSemaphores = signalSemaphores;

        VkSwapchainKHR swapChains[] = {swapChain};
        presentInfo.swapchainCount = 1;
        presentInfo.pSwapchains = swapChains;

        presentInfo.pImageIndices = &imageIndex;

        vkQueuePresentKHR(presentQueue, &presentInfo);
    }

    VkShaderModule createShaderModule(const std::vector<uint32_t>& code) {
        VkShaderModuleCreateInfo createInfo{};
        createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
        createInfo.codeSize = code.size() * sizeof(uint32_t);
        createInfo.pCode = code.data();

        VkShaderModule shaderModule;
        if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
            throw std::runtime_error("failed to create shader module!");
        }

        return shaderModule;
    }

    VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {
        for (const auto& availableFormat : availableFormats) {
            if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
                return availableFormat;
            }
        }

        return availableFormats[0];
    }

    VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR>& availablePresentModes) {
        for (const auto& availablePresentMode : availablePresentModes) {
            if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) {
                return availablePresentMode;
            }
        }

        return VK_PRESENT_MODE_FIFO_KHR;
    }

    VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {
        if (capabilities.currentExtent.width != std::numeric_limits<uint32_t>::max()) {
            return capabilities.currentExtent;
        } else {
            int width, height;
            glfwGetFramebufferSize(window, &width, &height);

            VkExtent2D actualExtent = {
                static_cast<uint32_t>(width),
                static_cast<uint32_t>(height)
            };

            actualExtent.width = std::clamp(actualExtent.width, capabilities.minImageExtent.width, capabilities.maxImageExtent.width);
            actualExtent.height = std::clamp(actualExtent.height, capabilities.minImageExtent.height, capabilities.maxImageExtent.height);

            return actualExtent;
        }
    }

    SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) {
        SwapChainSupportDetails details;

        vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &details.capabilities);

        uint32_t formatCount;
        vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);

        if (formatCount != 0) {
            details.formats.resize(formatCount);
            vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, details.formats.data());
        }

        uint32_t presentModeCount;
        vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);

        if (presentModeCount != 0) {
            details.presentModes.resize(presentModeCount);
            vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, details.presentModes.data());
        }

        return details;
    }

    bool isDeviceSuitable(VkPhysicalDevice device) {
        QueueFamilyIndices indices = findQueueFamilies(device);

        bool extensionsSupported = checkDeviceExtensionSupport(device);

        bool swapChainAdequate = false;
        if (extensionsSupported) {
            SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device);
            swapChainAdequate = !swapChainSupport.formats.empty() && !swapChainSupport.presentModes.empty();
        }

        return indices.isComplete() && extensionsSupported && swapChainAdequate;
    }

    bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
        uint32_t extensionCount;
        vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);

        std::vector<VkExtensionProperties> availableExtensions(extensionCount);
        vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());

        std::set<std::string> requiredExtensions(deviceExtensions.begin(), deviceExtensions.end());

        for (const auto& extension : availableExtensions) {
            requiredExtensions.erase(extension.extensionName);
        }

        return requiredExtensions.empty();
    }

    QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
        QueueFamilyIndices indices;

        uint32_t queueFamilyCount = 0;
        vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);

        std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
        vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());

        int i = 0;
        for (const auto& queueFamily : queueFamilies) {
            if (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) {
                indices.graphicsFamily = i;
            }

            VkBool32 presentSupport = false;
            vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport);

            if (presentSupport) {
                indices.presentFamily = i;
            }

            if (indices.isComplete()) {
                break;
            }

            i++;
        }

        return indices;
    }

    std::vector<const char*> getRequiredExtensions() {
        uint32_t glfwExtensionCount = 0;
        const char** glfwExtensions;
        glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);

        std::vector<const char*> extensions(glfwExtensions, glfwExtensions + glfwExtensionCount);

        if (enableValidationLayers) {
            extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
        }

        return extensions;
    }

    bool checkValidationLayerSupport() {
        uint32_t layerCount;
        vkEnumerateInstanceLayerProperties(&layerCount, nullptr);

        std::vector<VkLayerProperties> availableLayers(layerCount);
        vkEnumerateInstanceLayerProperties(&layerCount, availableLayers.data());

        for (const char* layerName : validationLayers) {
            bool layerFound = false;

            for (const auto& layerProperties : availableLayers) {
                if (strcmp(layerName, layerProperties.layerName) == 0) {
                    layerFound = true;
                    break;
                }
            }

            if (!layerFound) {
                return false;
            }
        }

        return true;
    }

    static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity, VkDebugUtilsMessageTypeFlagsEXT messageType, const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData, void* pUserData) {
        std::cerr << "validation layer: " << pCallbackData->pMessage << std::endl;

        return VK_FALSE;
    }
};

int main() {
    HelloTriangleApplication app;

    try {
        app.run();
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

十二、Vulkan 与 OpenGL / DirectX 12 的架构对比

特性维度VulkanOpenGLDirectX 12
驱动模型显式控制、低开销隐式状态机、高驱动开销显式控制、低开销
跨平台支持Windows / Linux / Android / macOS(MoltenVK)全平台(含 macOS)仅 Windows / Xbox
多线程渲染原生支持,无全局锁困难,Context 绑定限制多线程原生支持,Command List 多线程录制
内存管理显式内存分配器、内存绑定隐式管理显式 Heap / Resource 模型
着色器格式SPIR-V 标准中间码GLSL 源码或二进制DXIL / DXBC
学习曲线非常陡峭(数百行初始化)平缓(30 行画三角形)陡峭(与 Vulkan 相近)
调试友好度校验层完善但 API 复杂驱动内置调试信息PIX 工具链业界最佳
移动生态Google 官方推荐(Android)逐渐被淘汰(ES 3.2 后停滞)不支持

FAQ 常见问题

Q1:为什么编译运行后窗口显示黑色,没有红色三角形?

请按以下顺序排查:(1) 确认 VK_LAYER_KHRONOS_validation 已正确启用,观察命令行是否有 Validation Layer 报错;(2) 检查 Swap Chain 创建是否成功,尤其 imageExtent 是否为有效尺寸;(3) 确认 Render Pass 的 loadOp 设置了 CLEARpClearValues 未越界;(4) 检查 Cull Mode 是否错误地剔除了正面——由于这是硬编码的固定输出顶点,尝试将 cullMode 临时改为 VK_CULL_MODE_NONE 测试;(5) 验证 vkCmdDraw 的 vertexCount 参数为 3。

Q2:SPIR-V 字节码能否替代 GLSL 成为日常工作流?

不建议手工编写 SPIR-V 字节码。本文将 SPIR-V 内嵌仅为演示“零文件依赖”的示例工程。在真实项目中,应使用 glslangValidator(Khronos 官方)或 glslc(Google Shaderc)将 GLSL 源码离线编译为 .spv 文件,然后在运行时读取。Vulkan SDK 甚至提供了 glslang 库,允许在运行时编译 GLSL 到 SPIR-V。

Q3:每一帧都重新录制 Command Buffer 是否效率低下?

对于静态场景完全可以预录制。如果绘制命令在帧之间不变(如固定 UI、背景板),只需要在初始化阶段录制一次 Command Buffer,之后每帧直接复用提交即可。Command Buffer 的 flags 应去掉 ONE_TIME_SUBMIT_BIT。动态场景才需要每帧重新录制。另一个优化方案是使用 Secondary Command Buffer——在多个 Worker 线程中并行录制,最后由主线程在 Primary Command Buffer 中批量执行。

Q4:如何处理窗口大小改变(Resize)?

当窗口尺寸改变时,Swap Chain 的图像尺寸与 Framebuffer 不再匹配。Vulkan 的做法是:在 vkAcquireNextImageKHR 返回 VK_ERROR_OUT_OF_DATE_KHRVK_SUBOPTIMAL_KHR 时触发重建流程。具体步骤为:(1) 调用 vkDeviceWaitIdle 等待所有 GPU 工作完成;(2) 销毁所有 Framebuffer、ImageView、Render Pass 和旧的 Swap Chain;(3) 使用新的窗口尺寸重新创建 Swap Chain、ImageView、Render Pass 和 Framebuffer。

Q5:Vulkan 的显式同步对象应如何选择 —— Semaphore 还是 Fence?

两者的同步方向不同。Semaphore 是 GPU-GPU 同步原语,用于控制不同 Queue Submit 之间的执行顺序(如 Render Queue 完成后通知 Present Queue 开始交换)。Fence 是 CPU-GPU 同步原语,用于 CPU 等待 GPU 完成某项工作(如等待帧渲染完成后再释放资源)。Swap Chain 渲染通常需要同时使用两者:Semaphore 连接 Acquire-Submit-Present 的 GPU 内流水线,Fence 控制 CPU 每帧的资源回收安全窗口。

Q6:双帧飞行(MAX_FRAMES_IN_FLIGHT = 2)与 Swap Chain 图像数之间的关系是什么?

这两个概念容易混淆。Swap Chain 的图像数(通常为 2 或 3)是由 minImageCount 决定的,表示驱动维护的呈现队列长度。MAX_FRAMES_IN_FLIGHT 是 CPU 侧可以并行录制提交的帧数(即允许 CPU 超前 GPU 多少帧)。前者是驱动管理的资源池,后者是 CPU 调度策略。理论上 MAX_FRAMES_IN_FLIGHT 可以小于等于 Swap Chain 图像数,但不能大于(否则可能出现覆盖 GPU 仍在读取的图像)。

Q7:Validation Layer 报错 “VUID-VkPipelineViewportStateCreateInfo-viewportCount-01216” 是什么意思?

这个错误表示 VkPipelineViewportStateCreateInfo 中的 viewportCount 与 Pipeline 创建时配置的不一致。如果你启用了 VK_DYNAMIC_STATE_VIEWPORT 动态视口,Pipeline 创建时的 viewportCount 可以设为 1,然后在 vkCmdSetViewport 中设置实际值。请注意 Pipeline 和命令录制阶段的数量必须匹配。


总结

本文以一条完整的主线贯穿了 Vulkan 初始化与渲染管线的全部核心步骤:从创建 VkInstance、枚举物理设备并选择队列族,到创建 VkDeviceVkSurfaceKHR、配置 VkSwapchainKHR 的三项关键参数,再到定义 VkRenderPassVkFramebuffer、构建覆盖顶点输入/图元装配/光栅化/颜色混合的 VkPipeline,最后录制 VkCommandBuffer 并通过 VkQueueSubmitvkQueuePresentKHR 完成帧的 GPU 提交与屏幕呈现。

这份流程的复杂性不是冗余,而是 Vulkan 显式设计哲学的直接体现。与 OpenGL 的隐式状态机相比,Vulkan 要求开发者明确指出每一步的资源依赖和同步约束,换来的是多线程渲染的原生支持、极低的驱动开销和跨平台一致的行为保证。掌握这套初始化流程后,你就可以在已有基础上继续扩展:引入顶点缓冲对象(VBO)从 CPU 传入动态顶点数据、通过 Uniform Buffer 和 Descriptor Set 传递变换矩阵、创建深度附件实现正确的遮挡剔除、使用多重采样抗锯齿提升图像质量,乃至进入 Deferred Rendering、Shadow Mapping 和 Ray Tracing 的高级渲染领域。

Vulkan 的学习曲线陡峭,但回报是巨大的。当你在屏幕上看到第一个由自己亲手从零搭建的 Vulkan 三角形时,你就已经跨越了现代图形编程中最重要的门槛。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「graphics」更多文章

  1. OpenGL VAO/VBO/EBO 与顶点管理:高效顶点数据传输
  2. OpenGL Core Profile 与管线:现代OpenGL编程指南
  3. Vulkan 高级渲染技术:延迟渲染、光追与计算着色器