从引擎到Vulkan:十年开发者为何抛弃Unity/Unreal,直击底层渲染
1. 从引擎到API为什么我最终选择了Vulkan1.1 一个老开发者的祛魅时刻做了快十年的图形和游戏开发我经历过引擎的“蜜月期”也熬过被引擎“绑架”的阵痛期。最开始入行那会儿觉得Unity、Unreal这些引擎简直是神一样的存在——拖拖拽拽就能出画面跨平台一键打包物理、动画、UI全给你安排得明明白白。但项目做得越多越发现一个尴尬的事实引擎帮你省下的时间最后往往要以另一种形式还回去。性能瓶颈查不到底层、渲染管线改不动、包体越来越大、启动越来越慢、某个版本升级直接让项目编译不过……这些坑我踩了不止一次。直到去年做一个需要极致性能的实时渲染项目我彻底下定决心抛开引擎直接用Vulkan。这篇文章就是我这段时间从引擎迁移到Vulkan的完整心路和实操记录适合那些被引擎“惯坏”又想找回控制权的开发者也适合刚接触图形API、想搞清楚底层到底在干什么的朋友。先说结论Vulkan不是银弹它门槛高、代码量大、调试痛苦但它给你的控制力和性能上限是任何通用引擎都给不了的。如果你做的项目对帧率、延迟、内存占用有硬性要求或者你单纯想搞明白GPU到底是怎么工作的那Vulkan值得你投入时间。1.2 引擎到底帮我们做了什么又拿走了什么要理解为什么“祛魅”得先搞清楚引擎的本质。引擎本质上是一个中间层它把图形APIDirectX、OpenGL、Vulkan、Metal封装成更高层的抽象再在上面搭建一套完整的工具链。你写Instantiate(prefab)引擎在背后帮你做内存分配、组件注册、场景图更新、渲染队列排序、批次合并、状态切换……这一整套流程确实省事但代价是你失去了对细节的掌控。举个例子在Unity里你想自定义一个渲染Pass得用Scriptable Render Pipeline或者URP/HDRP的接口但这些接口本身又有一层抽象你没法直接控制命令缓冲的录制顺序也没法精细管理内存屏障。再比如Draw Call合并引擎的合批策略是通用的它不知道你的场景里哪些物体永远不变、哪些可以静态合并结果就是该合的没合不该合的乱合。而Vulkan把这些全部交还给你命令缓冲你自己录、内存你自己分配、同步你自己做、管线状态你自己管。听起来很吓人但一旦你理解了这套模型你会发现它其实非常“诚实”——你写的每一行代码GPU都会忠实地执行没有黑盒没有惊喜。1.3 Vulkan的核心优势控制力、性能、可预测性Vulkan相比引擎最大的三个优势我按重要性排序第一是控制力。Vulkan的API设计哲学就是“显式优于隐式”。你想用多少内存、什么时候上传纹理、用什么队列提交命令全部由你决定。这意味着你可以针对自己的场景做极致优化比如把静态几何体全部预烘焙到一个大缓冲里把动态物体单独管理把UI渲染放到独立的队列里并行执行。这些在引擎里要么做不到要么得跟引擎的架构搏斗。第二是性能。Vulkan的多线程命令录制能力是革命性的。传统OpenGL是单线程状态机你没法在多个线程里同时录制命令。Vulkan允许你开多个线程每个线程录制自己的命令缓冲最后在主线程统一提交。在现代多核CPU上这能带来数倍的性能提升。另外Vulkan的显式内存管理让你可以精确控制内存类型Device Local、Host Visible、Host Coherent等把频繁访问的数据放在最快的内存里。第三是可预测性。引擎的帧率波动往往来自不可控的GC、资源加载、合批策略变化。Vulkan没有GC资源生命周期完全由你管理帧时间可以做到非常稳定。这对于VR、竞技类游戏、工业仿真这些对延迟敏感的场景至关重要。1.4 适合直接上Vulkan的项目类型不是所有项目都适合抛弃引擎。我总结了几类最适合直接上Vulkan的场景实时渲染密集型比如建筑可视化、工业仿真、科学可视化需要渲染海量几何体或体素数据引擎的通用管线扛不住。低延迟交互VR/AR应用、云游戏串流、远程桌面帧时间必须稳定在个位数毫秒。嵌入式或定制硬件引擎的跨平台支持反而成了负担你需要针对特定GPU做深度优化。学习与研究想真正理解图形管线、GPU架构、并行计算Vulkan是最好的教材。反过来如果你做的是2D休闲游戏、快速原型、或者团队里没有图形底层经验的人那还是老老实实用引擎。工具没有优劣只有合不合适。2. Vulkan核心概念拆解从实例到交换链2.1 Instance与物理设备选择第一步就劝退Vulkan的初始化流程比OpenGL复杂得多第一步创建Instance就会遇到一堆概念VkApplicationInfo、VkInstanceCreateInfo、扩展、层、验证层……我刚开始看的时候也是一头雾水。但理解之后会发现这套设计其实很合理。VkInstance是Vulkan的全局上下文它代表你的应用与Vulkan运行时的连接。创建Instance时需要指定应用信息、需要的扩展比如VK_KHR_surface用于窗口系统集成、需要的层比如验证层用于调试。验证层是Vulkan开发中最重要的调试工具它会在你调用API出错时给出详细的错误信息强烈建议在开发阶段始终开启。创建完Instance后需要枚举物理设备vkEnumeratePhysicalDevices然后从中选择一个。选择逻辑通常包括是否支持需要的队列族、是否支持交换链、是否支持需要的特性比如几何着色器、 tessellation。我一般会写一个评分函数给独显加分、给支持更多队列的加分、给显存大的加分。注意物理设备选择不是一劳永逸的。如果你的应用支持多GPU需要在运行时动态选择。另外有些集成显卡在枚举时可能不报告某些队列族需要仔细检查。2.2 逻辑设备与队列族GPU的“工作通道”选好物理设备后需要创建逻辑设备VkDevice。逻辑设备是你与物理设备交互的接口创建时需要指定要启用的队列族。队列族是Vulkan中一个非常重要的概念GPU上的所有工作都是通过队列提交的不同的队列族支持不同的操作图形、计算、传输、稀疏绑定等。我通常会把图形、计算、传输分开到不同的队列族这样可以并行提交。但有些GPU只暴露一个通用队列族那就只能串行。创建逻辑设备时还要指定设备特性比如VK_KHR_swapchain扩展、各向异性过滤、采样率着色等这些特性必须在创建时启用运行时不能动态开启。队列的获取也很直接vkGetDeviceQueue传入队列族索引和队列索引。之后所有的命令提交都通过这个队列句柄。2.3 交换链与呈现窗口背后的复杂机制交换链Swapchain是Vulkan中与窗口系统交互的核心。它本质上是一组图像ImageGPU渲染到这些图像上然后由窗口系统呈现到屏幕。创建交换链时需要查询表面能力vkGetPhysicalDeviceSurfaceCapabilitiesKHR、支持的格式、呈现模式。呈现模式决定了图像如何呈现到屏幕FIFO是垂直同步MAILBOX是三缓冲IMMEDIATE是立即呈现。我一般会根据应用类型选择竞技游戏用MAILBOX降低延迟普通应用用FIFO省电。交换链的创建还涉及图像数量、图像格式、图像用途、共享模式等参数。图像数量通常取minImageCount 1这样可以避免等待。图像格式要选GPU支持且窗口系统支持的常见的是VK_FORMAT_B8G8R8A8_SRGB。提示交换链在窗口大小改变时需要重建。重建时要等待所有使用旧交换链的命令执行完毕然后销毁旧交换链、创建新的。这个过程容易出错建议封装成一个函数。2.4 命令缓冲与同步Vulkan的“交通规则”命令缓冲Command Buffer是Vulkan中录制命令的地方。你可以把它想象成一个“任务清单”里面记录了要执行的渲染、计算、传输操作。命令缓冲从命令池Command Pool分配命令池与队列族绑定。录制命令缓冲的流程是vkBeginCommandBuffer- 录制各种命令 -vkEndCommandBuffer。录制完成后通过vkQueueSubmit提交到队列执行。同步是Vulkan中最容易出错的部分。Vulkan提供了多种同步原语Fence栅栏、Semaphore信号量、Event事件、Barrier屏障。Fence用于CPU等待GPUSemaphore用于GPU内部队列间的同步Barrier用于同一队列内命令间的同步。我踩过最大的坑就是忘记加Barrier。比如你刚上传完纹理紧接着就要在渲染中使用它如果不加BarrierGPU可能会在纹理上传完成前就开始渲染导致画面花屏。Barrier要指定源阶段、目标阶段、源访问掩码、目标访问掩码这些参数必须精确匹配否则验证层会报错。2.5 渲染通道与帧缓冲管线的“舞台”渲染通道Render Pass是Vulkan中描述渲染过程的对象。它定义了附件的数量、格式、加载/存储操作、子通道依赖等。帧缓冲Framebuffer则是渲染通道的具体实例绑定了实际的图像视图。渲染通道的设计是为了让GPU驱动能够提前知道渲染的依赖关系从而做优化。比如你告诉驱动“这个附件在渲染开始时清空渲染结束后存储”驱动就可以做相应的优化。子通道Subpass允许你在一个渲染通道内做多次渲染比如先渲染G-Buffer再渲染光照最后做后处理。我一般会把渲染通道设计得尽量简单每个通道只做一件事。复杂的多通道渲染虽然能省一点带宽但调试起来非常痛苦。3. 从零搭建Vulkan渲染器的实操记录3.1 环境准备与项目结构我用的环境是Windows 10 Visual Studio 2022 Vulkan SDK 1.3.268。Vulkan SDK可以从LunarG官网下载安装后会自动配置环境变量。项目结构我习惯这样组织project/ ├── src/ │ ├── main.cpp │ ├── vulkan/ │ │ ├── instance.cpp │ │ ├── device.cpp │ │ ├── swapchain.cpp │ │ ├── pipeline.cpp │ │ ├── command.cpp │ │ └── sync.cpp │ ├── renderer/ │ │ ├── renderer.cpp │ │ └── mesh.cpp │ └── utils/ │ ├── file.cpp │ └── math.cpp ├── shaders/ │ ├── triangle.vert │ └── triangle.frag ├── third_party/ │ ├── glfw/ │ ├── glm/ │ └── vulkan/ └── CMakeLists.txt第三方库我用了GLFW窗口、GLM数学、Vulkan-HppC绑定可选。如果你不想用C绑定直接用C API也行但代码会更冗长。CMake配置的关键是找到Vulkan库和头文件find_package(Vulkan REQUIRED) target_link_libraries(${PROJECT_NAME} PRIVATE Vulkan::Vulkan glfw glm)3.2 实例与验证层配置创建Instance的代码大概长这样VkApplicationInfo appInfo{}; appInfo.sType VK_STRUCTURE_TYPE_APPLICATION_INFO; appInfo.pApplicationName VulkanRenderer; appInfo.applicationVersion VK_MAKE_VERSION(1, 0, 0); appInfo.pEngineName NoEngine; appInfo.engineVersion VK_MAKE_VERSION(1, 0, 0); appInfo.apiVersion VK_API_VERSION_1_3; VkInstanceCreateInfo createInfo{}; createInfo.sType VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; createInfo.pApplicationInfo appInfo; // 扩展 std::vectorconst char* extensions { VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_WIN32_SURFACE_EXTENSION_NAME }; createInfo.enabledExtensionCount extensions.size(); createInfo.ppEnabledExtensionNames extensions.data(); // 验证层 const std::vectorconst char* validationLayers { VK_LAYER_KHRONOS_validation }; createInfo.enabledLayerCount validationLayers.size(); createInfo.ppEnabledLayerNames validationLayers.data(); VkInstance instance; if (vkCreateInstance(createInfo, nullptr, instance) ! VK_SUCCESS) { throw std::runtime_error(failed to create instance!); }验证层是调试利器但发布版本一定要关掉否则性能会下降。我一般用宏来控制#ifdef NDEBUG const bool enableValidationLayers false; #else const bool enableValidationLayers true; #endif3.3 物理设备与逻辑设备初始化选择物理设备的逻辑uint32_t deviceCount 0; vkEnumeratePhysicalDevices(instance, deviceCount, nullptr); std::vectorVkPhysicalDevice 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_DISCRETE_GPU) { physicalDevice device; break; } }创建逻辑设备时需要指定队列族QueueFamilyIndices indices findQueueFamilies(physicalDevice); std::vectorVkDeviceQueueCreateInfo queueCreateInfos; std::setuint32_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{}; deviceFeatures.samplerAnisotropy VK_TRUE; VkDeviceCreateInfo createInfo{}; createInfo.sType VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; createInfo.queueCreateInfoCount queueCreateInfos.size(); createInfo.pQueueCreateInfos queueCreateInfos.data(); createInfo.pEnabledFeatures deviceFeatures; createInfo.enabledExtensionCount deviceExtensions.size(); createInfo.ppEnabledExtensionNames deviceExtensions.data(); VkDevice device; vkCreateDevice(physicalDevice, createInfo, nullptr, device);3.4 交换链创建与图像视图交换链创建是初始化中最复杂的部分。首先要查询表面能力VkSurfaceCapabilitiesKHR capabilities; vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, surface, capabilities); uint32_t formatCount; vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, formatCount, nullptr); std::vectorVkSurfaceFormatKHR formats(formatCount); vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, formatCount, formats.data()); uint32_t presentModeCount; vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, presentModeCount, nullptr); std::vectorVkPresentModeKHR presentModes(presentModeCount); vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, presentModeCount, presentModes.data());选择格式和呈现模式VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vectorVkSurfaceFormatKHR formats) { for (const auto format : formats) { if (format.format VK_FORMAT_B8G8R8A8_SRGB format.colorSpace VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) { return format; } } return formats[0]; } VkPresentModeKHR chooseSwapPresentMode(const std::vectorVkPresentModeKHR modes) { for (const auto mode : modes) { if (mode VK_PRESENT_MODE_MAILBOX_KHR) { return mode; } } return VK_PRESENT_MODE_FIFO_KHR; }创建交换链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; 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 capabilities.currentTransform; createInfo.compositeAlpha VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; createInfo.presentMode presentMode; createInfo.clipped VK_TRUE; createInfo.oldSwapchain VK_NULL_HANDLE; VkSwapchainKHR swapchain; vkCreateSwapchainKHR(device, createInfo, nullptr, swapchain);获取交换链图像并创建图像视图uint32_t swapchainImageCount; vkGetSwapchainImagesKHR(device, swapchain, swapchainImageCount, nullptr); std::vectorVkImage swapchainImages(swapchainImageCount); vkGetSwapchainImagesKHR(device, swapchain, swapchainImageCount, swapchainImages.data()); std::vectorVkImageView swapchainImageViews(swapchainImageCount); for (size_t i 0; i swapchainImages.size(); i) { VkImageViewCreateInfo viewInfo{}; viewInfo.sType VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; viewInfo.image swapchainImages[i]; viewInfo.viewType VK_IMAGE_VIEW_TYPE_2D; viewInfo.format surfaceFormat.format; viewInfo.components.r VK_COMPONENT_SWIZZLE_IDENTITY; viewInfo.components.g VK_COMPONENT_SWIZZLE_IDENTITY; viewInfo.components.b VK_COMPONENT_SWIZZLE_IDENTITY; viewInfo.components.a VK_COMPONENT_SWIZZLE_IDENTITY; viewInfo.subresourceRange.aspectMask VK_IMAGE_ASPECT_COLOR_BIT; viewInfo.subresourceRange.baseMipLevel 0; viewInfo.subresourceRange.levelCount 1; viewInfo.subresourceRange.baseArrayLayer 0; viewInfo.subresourceRange.layerCount 1; vkCreateImageView(device, viewInfo, nullptr, swapchainImageViews[i]); }3.5 图形管线与着色器模块图形管线是Vulkan中最复杂的对象之一。它包含了顶点输入、输入装配、视口、光栅化、多重采样、深度测试、颜色混合、动态状态、着色器阶段等配置。我一般会把管线创建封装成一个Builder模式避免代码过于冗长。着色器需要编译成SPIR-V格式。我用的工具是glslangValidatorglslangValidator -V triangle.vert -o triangle.vert.spv glslangValidator -V triangle.frag -o triangle.frag.spv加载SPIR-V并创建着色器模块std::vectorchar vertShaderCode readFile(shaders/triangle.vert.spv); VkShaderModule vertShaderModule createShaderModule(vertShaderCode); VkPipelineShaderStageCreateInfo vertStageInfo{}; vertStageInfo.sType VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; vertStageInfo.stage VK_SHADER_STAGE_VERTEX_BIT; vertStageInfo.module vertShaderModule; vertStageInfo.pName main;管线创建的关键是VkGraphicsPipelineCreateInfo里面要填一大堆结构体。我踩过最大的坑是管线布局Pipeline Layout它定义了着色器可以访问的资源描述符集、推送常量。如果管线布局和着色器里的资源不匹配验证层会报错但错误信息往往很隐晦。3.6 命令录制与帧循环帧循环是Vulkan渲染器的核心。每一帧的流程大概是等待上一帧的Fence从交换链获取图像vkAcquireNextImageKHR重置命令缓冲录制命令缓冲提交命令缓冲vkQueueSubmit呈现图像vkQueuePresentKHR代码框架void drawFrame() { vkWaitForFences(device, 1, inFlightFences[currentFrame], VK_TRUE, UINT64_MAX); uint32_t imageIndex; VkResult result vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, imageIndex); vkResetFences(device, 1, inFlightFences[currentFrame]); vkResetCommandBuffer(commandBuffers[currentFrame], 0); recordCommandBuffer(commandBuffers[currentFrame], imageIndex); VkSubmitInfo submitInfo{}; submitInfo.sType VK_STRUCTURE_TYPE_SUBMIT_INFO; VkSemaphore waitSemaphores[] {imageAvailableSemaphores[currentFrame]}; VkPipelineStageFlags waitStages[] {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; submitInfo.waitSemaphoreCount 1; submitInfo.pWaitSemaphores waitSemaphores; submitInfo.pWaitDstStageMask waitStages; submitInfo.commandBufferCount 1; submitInfo.pCommandBuffers commandBuffers[currentFrame]; VkSemaphore signalSemaphores[] {renderFinishedSemaphores[currentFrame]}; submitInfo.signalSemaphoreCount 1; submitInfo.pSignalSemaphores signalSemaphores; vkQueueSubmit(graphicsQueue, 1, submitInfo, inFlightFences[currentFrame]); VkPresentInfoKHR presentInfo{}; presentInfo.sType VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount 1; presentInfo.pWaitSemaphores signalSemaphores; presentInfo.swapchainCount 1; presentInfo.pSwapchains swapchain; presentInfo.pImageIndices imageIndex; vkQueuePresentKHR(presentQueue, presentInfo); currentFrame (currentFrame 1) % MAX_FRAMES_IN_FLIGHT; }注意MAX_FRAMES_IN_FLIGHT一般取2或3。取2可以降低延迟取3可以更好地利用GPU。但取太多会导致输入延迟增加需要根据应用类型权衡。4. 常见问题与排查技巧实录4.1 验证层报错速查表Vulkan验证层的错误信息有时候很晦涩我整理了一份常见错误和解决方法错误信息关键词可能原因解决方法VUID-VkSubmitInfo-pWaitSemaphores信号量重复使用或未重置确保每帧使用独立的信号量或正确重置VUID-vkCmdPipelineBarrier-srcStageMask屏障阶段掩码不匹配检查源阶段和目标阶段是否覆盖了实际访问VUID-VkImageCreateInfo-usage图像用途与格式不兼容检查格式是否支持指定的用途VUID-VkGraphicsPipelineCreateInfo-layout管线布局与着色器资源不匹配检查描述符集布局和推送常量范围VUID-vkAcquireNextImageKHR-semaphore信号量已被使用确保信号量在获取图像前处于未信号状态VUID-VkSwapchainCreateInfoKHR-imageExtent交换链尺寸超出范围检查窗口大小和表面能力4.2 内存泄漏与资源生命周期管理Vulkan没有自动垃圾回收所有资源必须手动销毁。我踩过最大的坑是忘记销毁命令池导致程序退出时验证层报了一堆泄漏。后来我养成了一个习惯每个vkCreate*调用都对应一个vkDestroy*并且用RAII包装。比如用std::unique_ptr配合自定义删除器templatetypename T using VulkanHandle std::unique_ptrT, std::functionvoid(T*); VulkanHandleVkBuffer createBuffer(...) { VkBuffer buffer; vkCreateBuffer(device, createInfo, nullptr, buffer); return VulkanHandleVkBuffer(buffer, [device](VkBuffer* b) { vkDestroyBuffer(device, *b, nullptr); }); }但这样写有点绕更简单的方式是封装一个VulkanContext类在析构函数里统一销毁所有资源。关键是销毁顺序要正确先销毁依赖别人的资源再销毁被依赖的资源。比如先销毁管线、再销毁管线布局、再销毁描述符集布局、最后销毁设备。4.3 性能调优从60帧到144帧的实战我做的第一个Vulkan项目是一个简单的3D场景初始帧率只有60帧左右。经过一系列优化最终稳定在144帧。主要优化手段第一减少命令缓冲录制开销。把静态几何体的命令录制到单独的二级命令缓冲每帧只需要执行不需要重新录制。动态物体才每帧录制。第二使用推送常量代替Uniform Buffer。对于频繁更新但数据量小的参数比如模型矩阵推送常量比Uniform Buffer更快因为它不需要额外的内存分配和描述符更新。第三优化内存类型选择。把频繁访问的顶点数据放在DEVICE_LOCAL内存把每帧更新的Uniform数据放在HOST_VISIBLE | HOST_COHERENT内存。如果GPU支持DEVICE_LOCAL | HOST_VISIBLE比如AMD的ReBAR优先用这种。第四减少管线切换。把使用相同管线的物体放在一起渲染避免频繁切换管线状态。我一般会按材质和着色器排序渲染队列。第五使用多线程命令录制。把场景分成多个区域每个区域一个线程录制命令缓冲最后在主线程统一提交。这个优化在多核CPU上效果非常明显。4.4 跨平台兼容性踩坑记录Vulkan虽然号称跨平台但不同GPU厂商的实现差异还是很大的。我踩过的坑包括NVIDIA驱动对验证层更严格有些在AMD上能跑的代码在NVIDIA上会报错。Intel集成显卡的队列族划分不同有些只暴露一个通用队列族需要做兼容处理。移动端GPUAdreno、Mali对内存类型支持有限DEVICE_LOCAL | HOST_VISIBLE可能不可用。交换链格式在不同平台上不同Windows上常见B8G8R8A8_SRGBLinux上可能是R8G8B8A8_SRGB。我的建议是在目标平台上尽早测试不要等到最后才做兼容性适配。另外验证层一定要在所有平台上开启它能帮你发现很多潜在问题。4.5 从引擎迁移的思维转变从引擎迁移到Vulkan最大的挑战不是API本身而是思维方式的转变。在引擎里你习惯了“声明式”编程告诉引擎你想要什么引擎帮你实现。在Vulkan里你必须“命令式”编程告诉GPU每一步做什么怎么做。举个例子在Unity里你创建一个材质设置纹理引擎自动帮你管理纹理的上传、绑定、采样。在Vulkan里你需要手动创建图像、分配内存、上传数据、创建图像视图、创建采样器、创建描述符集、更新描述符集、绑定到管线……每一步都不能少。这种转变一开始很痛苦但一旦适应了你会发现你对渲染管线的理解上了一个台阶。你不再是一个“引擎使用者”而是一个“图形程序员”。5. 工具链与调试让Vulkan开发不那么痛苦5.1 验证层与调试工具链配置验证层是Vulkan开发中最重要的工具没有之一。它会在你调用API出错时给出详细的错误信息包括错误代码、参数值、调用栈。配置验证层的方法前面已经讲过这里补充几个实用技巧第一使用VK_EXT_debug_utils扩展。它比旧的VK_EXT_debug_report更强大支持给资源命名、给命令缓冲加标签、插入调试标记。这样在RenderDoc里看的时候你能直接看到每个资源的名字而不是一堆句柄。VkDebugUtilsObjectNameInfoEXT nameInfo{}; nameInfo.sType VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT; nameInfo.objectType VK_OBJECT_TYPE_IMAGE; nameInfo.objectHandle (uint64_t)image; nameInfo.pObjectName AlbedoTexture; vkSetDebugUtilsObjectNameEXT(device, nameInfo);第二配置验证层的消息回调。默认情况下验证层的消息会输出到标准错误。你可以设置一个回调函数把消息重定向到日志文件或调试器。VkDebugUtilsMessengerCreateInfoEXT createInfo{}; createInfo.sType VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT; createInfo.messageSeverity 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;第三使用VK_LAYER_KHRONOS_validation的性能警告。验证层不仅能查错误还能给出性能警告比如“这个屏障太宽泛了”、“这个内存分配效率低”。这些警告对优化很有帮助。5.2 RenderDoc与Nsight实战RenderDoc是我最常用的Vulkan调试工具。它可以捕获一帧的完整渲染过程查看每个Draw Call的输入输出、管线状态、资源绑定。使用方法是启动RenderDoc附加到你的应用按F12捕获一帧。在RenderDoc里我一般会检查Draw Call数量是否过多能否合并。管线状态切换是否频繁切换管线能否排序优化。纹理绑定是否有不必要的纹理切换。屏障是否有过宽或过窄的屏障。Nsight Graphics是NVIDIA的调试工具功能更强大但只支持NVIDIA GPU。它可以查看GPU的硬件计数器比如SM占用率、内存带宽、缓存命中率。如果你用的是NVIDIA显卡强烈建议配合使用。5.3 性能分析找到瓶颈的五个步骤性能分析是Vulkan开发中不可或缺的环节。我一般按以下步骤排查第一步确定是CPU瓶颈还是GPU瓶颈。用vkGetQueryPoolResults查询时间戳或者用RenderDoc看CPU和GPU的时间线。如果CPU时间远大于GPU时间说明CPU瓶颈反之则是GPU瓶颈。第二步如果是CPU瓶颈检查命令录制和提交。命令录制是否太慢提交是否太频繁同步是否太多我一般会用多线程录制来缓解CPU瓶颈。第三步如果是GPU瓶颈检查渲染管线。是顶点处理慢还是像素处理慢用Nsight看SM占用率和内存带宽。如果SM占用率高但帧率低说明计算量大如果内存带宽高说明纹理采样或缓冲访问多。第四步检查屏障和同步。过宽的屏障会导致GPU等待过窄的屏障会导致数据竞争。我一般会用VK_PIPELINE_STAGE_ALL_COMMANDS_BIT做保守屏障确认没问题后再逐步收窄。第五步检查内存分配。频繁的内存分配和释放会导致性能下降。我一般会预分配大块内存自己管理子分配。5.4 着色器调试与SPIR-V优化着色器调试是Vulkan开发中的另一个痛点。GLSL编译成SPIR-V后调试信息会丢失很难定位问题。我的做法是第一用glslangValidator的-g选项生成调试信息。这样在RenderDoc里可以看到变量名和行号。第二用spirv-cross把SPIR-V反编译回GLSL检查编译器是否做了意外的优化。第三用spirv-opt优化SPIR-V。它可以做死代码消除、常量折叠、内联等优化减小着色器体积提升执行效率。spirv-opt -O triangle.vert.spv -o triangle.vert.opt.spv第四注意着色器的资源绑定。Vulkan的着色器资源绑定是显式的layout(binding 0)必须和描述符集布局匹配。我踩过好几次坑都是因为绑定号写错了。6. 从Vulkan出发的扩展方向6.1 光线追踪与Vulkan Ray TracingVulkan的Ray Tracing扩展VK_KHR_ray_tracing_pipeline让实时全局光照成为可能。我最近在做一个混合渲染的项目用光栅化渲染主场景用光线追踪渲染反射和阴影。效果非常惊艳但性能开销也很大。使用Ray Tracing扩展需要创建加速结构BLAS和TLAS、光线追踪管线、着色器绑定表SBT。代码量比传统光栅化管线大得多但灵活性也高得多。如果你对实时全局光照感兴趣这是值得投入的方向。6.2 计算着色器与GPGPUVulkan的计算着色器Compute Shader可以用于通用计算比如粒子模拟、物理计算、图像处理。我最近用计算着色器做了一个流体模拟性能比CPU实现快了近百倍。计算着色器的关键是工作组大小和共享内存的配置。工作组大小要匹配GPU的SIMD宽度共享内存要尽量利用。我一般会用VK_KHR_shader_subgroup扩展来做子组操作进一步提升性能。6.3 多线程渲染架构设计Vulkan的多线程命令录制是它最大的优势之一。我现在的渲染器架构是主线程负责逻辑更新、场景管理、提交命令。渲染线程池每个线程负责一个渲染区域的命令录制。传输线程负责资源上传和内存管理。线程间通过任务队列通信用std::future和std::promise做同步。这个架构在8核CPU上能带来3-4倍的性能提升。6.4 未来方向Mesh Shader与Vulkan 1.3新特性Vulkan 1.3引入了很多新特性比如VK_EXT_mesh_shader、VK_EXT_descriptor_buffer、VK_KHR_dynamic_rendering。其中Mesh Shader是我最看好的方向它用计算着色器的模型来做几何处理比传统顶点管线灵活得多。Dynamic Rendering简化了渲染通道的创建不再需要VkRenderPass和VkFramebuffer直接指定附件和加载操作。这个特性在Vulkan 1.3中是核心功能不需要扩展。Descriptor Buffer允许把描述符直接放在缓冲里减少了描述符集的更新开销。对于需要频繁更新描述符的场景这个特性很有用。最后分享一个我踩过的坑不要在vkAcquireNextImageKHR返回VK_ERROR_OUT_OF_DATE_KHR时直接退出。这个错误表示交换链需要重建正确的做法是重建交换链然后重试。我一开始没处理这个错误结果窗口大小改变时程序直接崩溃。后来加上了重建逻辑才稳定下来。另外如果你在开发过程中遇到VK_ERROR_DEVICE_LOST不要慌。这个错误通常表示GPU崩溃了原因可能是着色器死循环、内存越界、或者驱动bug。用验证层和RenderDoc排查一般都能找到问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →