4、Vulkan基础入门:Vulkan vs OpenGL ES、实例与设备创建、命令缓冲区

好,咱们进入Vulkan的世界。说实话,我第一次接触Vulkan时,心里是有点抵触的。你想想看,OpenGL ES写得好好的,为什么要换一个更复杂的东西?但当我真正在8155上跑起来之后,嗯,真香。

4.1 Vulkan vs OpenGL ES:为什么我们要换赛道?

先说说最直观的区别。OpenGL ES像个保姆,帮你管内存、管同步、管驱动状态。你只管调用API,剩下的它包了。但代价是什么?性能天花板低,多线程支持差,驱动开销大。

Vulkan呢?它像个工具箱。给你锤子、扳手、螺丝刀,你自己决定怎么用。没有全局状态,没有隐式同步,一切都要你亲手操办。听起来麻烦,但换来的是极致的控制力。

我在项目中遇到过这样一个场景:用OpenGL ES渲染一帧,CPU端耗时大约3ms,其中驱动开销占了1.5ms。换成Vulkan后,同样的渲染,CPU端降到0.8ms。为什么?因为Vulkan允许你提前准备好所有命令,一次性提交,驱动几乎不做额外工作。

核心差异速览:

对比项 OpenGL ES Vulkan
驱动开销 高(隐式状态管理) 低(显式控制)
多线程支持 弱(单线程为主) 强(可多线程构建命令)
错误处理 运行时报错 验证层+返回值
学习曲线 平缓 陡峭
性能上限 中等

说白了,如果你只是做个简单的UI或者2D游戏,OpenGL ES完全够用。但如果你要在8155上做复杂的3D HMI、多屏渲染、或者需要榨干GPU每一分性能,Vulkan是唯一的选择。

4.2 实例与设备创建:从零搭建Vulkan环境

好,咱们动手。创建Vulkan环境分两步:先创建实例(VkInstance),再创建设备(VkDevice)。实例代表整个Vulkan库的上下文,设备代表具体的GPU。

4.2.1 创建VkInstance

实例创建其实不复杂,但有个坑:你必须告诉Vulkan你要用哪些扩展和验证层。我个人习惯在调试阶段开启验证层,发布时关掉。

// 应用信息
VkApplicationInfo appInfo = {};
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
appInfo.pApplicationName = "MyApp";
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_2;

// 实例创建信息
VkInstanceCreateInfo createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo = &appInfo;

// 扩展(比如需要surface扩展才能显示到屏幕)
uint32_t extensionCount = 0;
const char** extensions = glfwGetRequiredInstanceExtensions(&extensionCount);
createInfo.enabledExtensionCount = extensionCount;
createInfo.ppEnabledExtensionNames = extensions;

// 验证层(调试用)
const char* validationLayers[] = {"VK_LAYER_KHRONOS_validation"};
createInfo.enabledLayerCount = 1;
createInfo.ppEnabledLayerNames = validationLayers;

VkInstance instance;
if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) {
    // 处理错误
}

避坑指南:我曾经在8155上遇到一个诡异的问题——实例创建失败,返回错误码-3。查了半天,发现是忘了检查验证层是否可用。后来我加了一段枚举验证层的代码,问题迎刃而解。建议你在创建实例前,先用 vkEnumerateInstanceLayerProperties 检查一下。

4.2.2 选择物理设备并创建逻辑设备

实例创建好后,下一步是枚举物理设备(GPU)。8155上通常只有一个GPU,但为了代码健壮性,我建议你写一个选择逻辑。

// 枚举物理设备
uint32_t deviceCount = 0;
vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
std::vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

// 选择第一个支持图形队列的设备
VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
for (const auto& device : devices) {
    VkPhysicalDeviceProperties props;
    vkGetPhysicalDeviceProperties(device, &props);
    if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU) {
        physicalDevice = device;
        break;
    }
}

选好物理设备后,创建逻辑设备。这里要指定队列族——说白了就是告诉Vulkan,你要用哪些类型的队列(图形、计算、传输等)。

// 查找图形队列族
uint32_t queueFamilyIndex = UINT32_MAX;
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, nullptr);
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, queueFamilies.data());

for (uint32_t i = 0; i < queueFamilyCount; i++) {
    if (queueFamilies[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) {
        queueFamilyIndex = i;
        break;
    }
}

// 创建逻辑设备
float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueCreateInfo = {};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = queueFamilyIndex;
queueCreateInfo.queueCount = 1;
queueCreateInfo.pQueuePriorities = &queuePriority;

VkDeviceCreateInfo deviceCreateInfo = {};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceCreateInfo.queueCreateInfoCount = 1;
deviceCreateInfo.pQueueCreateInfos = &queueCreateInfo;

// 启用设备扩展(比如交换链)
const char* deviceExtensions[] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME};
deviceCreateInfo.enabledExtensionCount = 1;
deviceCreateInfo.ppEnabledExtensionNames = deviceExtensions;

VkDevice device;
vkCreateDevice(physicalDevice, &deviceCreateInfo, nullptr, &device);

// 获取队列句柄
VkQueue graphicsQueue;
vkGetDeviceQueue(device, queueFamilyIndex, 0, &graphicsQueue);

注意:创建逻辑设备时,一定要启用你需要的扩展。比如要在屏幕上显示渲染结果,就必须启用 VK_KHR_swapchain 扩展。我见过不少新手忘了这步,结果创建交换链时一直报错。

4.3 命令缓冲区:Vulkan的“指令清单”

命令缓冲区是Vulkan最核心的概念之一。你可以把它想象成一份购物清单——你把所有要做的操作(绑定管线、设置顶点数据、绘制三角形)都写上去,然后一次性交给GPU执行。

为什么这样做?因为减少了CPU和GPU之间的交互次数。OpenGL ES每调用一次glDrawArrays,CPU都要和GPU同步一次。Vulkan呢?你提前准备好所有命令,然后一次提交,GPU自己慢慢执行。

4.3.1 创建命令池和命令缓冲区

命令缓冲区从命令池中分配。命令池的作用是管理内存,说白了就是给命令缓冲区分配存储空间。

// 创建命令池
VkCommandPoolCreateInfo poolInfo = {};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = queueFamilyIndex;
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;

VkCommandPool commandPool;
vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool);

// 分配命令缓冲区
VkCommandBufferAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;

VkCommandBuffer commandBuffer;
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);

4.3.2 记录命令

记录命令的过程,就像在写一份详细的施工方案。你要告诉GPU:开始渲染、清空颜色、绑定管线、绘制、结束渲染。

// 开始记录命令
VkCommandBufferBeginInfo beginInfo = {};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;

vkBeginCommandBuffer(commandBuffer, &beginInfo);

// 开始渲染通道
VkRenderPassBeginInfo renderPassInfo = {};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassInfo.renderPass = renderPass;
renderPassInfo.framebuffer = framebuffer;
renderPassInfo.renderArea.offset = {0, 0};
renderPassInfo.renderArea.extent = {width, height};

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);

// 绘制三角形(3个顶点)
vkCmdDraw(commandBuffer, 3, 1, 0, 0);

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

// 结束记录命令
vkEndCommandBuffer(commandBuffer);

个人经验:我在8155上调试时发现,命令缓冲区的记录顺序很重要。比如,你必须在 vkCmdBeginRenderPass 之后才能调用绘制命令,否则会报错。另外,我习惯把命令缓冲区的记录放在单独的线程中,这样主线程可以继续处理输入事件,充分利用多核性能。

4.3.3 提交命令缓冲区

命令记录完成后,提交到队列执行。这里要注意同步问题——你需要用信号量或栅栏来确保GPU执行完毕。

// 提交命令
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

// 等待信号量(可选)
VkSemaphore waitSemaphore = ...;
VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &waitSemaphore;
submitInfo.pWaitDstStageMask = waitStages;

// 信号量通知(可选)
VkSemaphore signalSemaphore = ...;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &signalSemaphore;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);

// 等待GPU完成(简单粗暴,但效率低)
vkQueueWaitIdle(graphicsQueue);

警告:千万不要在每一帧都调用 vkQueueWaitIdle!这会让CPU和GPU完全同步,性能直接打回OpenGL ES的原形。正确的做法是用信号量或栅栏来做异步同步。我曾经在项目初期偷懒用了 vkQueueWaitIdle,结果帧率从60fps掉到了30fps,教训深刻。

4.4 小结

好了,这一章的内容就到这里。我们对比了Vulkan和OpenGL ES的差异,亲手创建了实例和设备,还学会了命令缓冲区的使用。说实话,Vulkan的学习曲线确实陡峭,但一旦你掌握了这些基础,后面的内容就会顺畅很多。

下一章,我们会深入渲染管线的核心——图形管线与着色器。到时候我会分享一些在8155上优化着色器的实战技巧,敬请期待。