Vulkan 动态渲染
前言
开发环境:Vulkan 1.3.2 + Vulkan SDK + VS 2022。语言 C++ vulkan.hpp。依赖vk-bootstrap,SDL3。
很久以前学Vulkan学得不彻底,写引擎的时候才发现那么困难,于是重新回来巩固一下Vulkan基础。并发现了很多小细节大学问。
动态渲染
我们今天的主角是Vulkan的一个核心特性,切确的说是Vulkan1.3及之后的核心特性。在此之前“动态渲染”(Dynamic Rendering)以拓展的形式存在。也就是说,当某个新功能新特性符合时代发展,符合人心所向的时候,KHR那帮人就会把它作为新的“核心特性”纳入规范中。
动态渲染的提出主要是为了解决RenderPass对象和Framebuffer对象与管线对象之间的复杂耦合问题。说是解耦,简化开发,实际上就是不用写RenderPass和Framebuffer那几坨复杂的结构体,该写的管线还得写(甚至还多一点)。
关键步骤
1.检查可用性
如果你的Vulkan版本是1.3以下,那么需要先启用相应的拓展VK_KHR_dynamic_rendering并在创建设备时链接相关结构体。如果是1.3以上就不用了是吧哈哈太好了!实则不然,也要在1.3的特性中找到并启用该特性。
也就是说,即使是Vulkan的核心特性,具体设备也不一定支持,即使支持,也要显示启用。为了简化复杂的拓展,特性检索等等,这里我用到了一个叫vk-bootstrap的库,里面就是帮开发者处理那些匪夷所思的模板代码的。
它使用方法也极为简单啊,就差不多这样一下就好了:
vkb::PhysicalDeviceSelector pds{ ibr.value(), g_Surface };
auto pdsr = pds.add_required_extension(VK_KHR_SWAPCHAIN_EXTENSION_NAME).set_required_features_13({ .dynamicRendering = true }).select();
注意到有些功能特性是需要对于版本的结构体才能找到的,所以这里对应features_13意为1.3版本的特性。 而且这个特性是一定一定一定要启用的,不然直接跑不了。
2.拓展管线
让我们略过一成不变的初始化过程,来到图形管线的创建阶段。
// 管线创建信息,renderpass 可以直接置空
vk::GraphicsPipelineCreateInfo pipeline_info{};
pipeline_info.stageCount = shader_stages.size();
pipeline_info.pStages = shader_stages.data();
pipeline_info.pVertexInputState = &vertex_input_info;
pipeline_info.pInputAssemblyState = &input_assembly_info;
pipeline_info.pViewportState = &viewport_info;
pipeline_info.pRasterizationState = &rasterization_info;
pipeline_info.pMultisampleState = &multisample_info;
pipeline_info.pColorBlendState = &color_blend_info;
pipeline_info.pDynamicState = &dynamic_state_info;
pipeline_info.layout = g_PipelineLayout;// 动态渲染的拓展结构体
std::vector<vk::Format> color_formats = { vk::Format::eB8G8R8A8Unorm };
vk::PipelineRenderingCreateInfo pipeline_rendering_info{};
pipeline_rendering_info.colorAttachmentCount = color_formats.size();
pipeline_rendering_info.pColorAttachmentFormats = color_formats.data();// 链接到结构体chain上
pipeline_info.pNext = &pipeline_rendering_info;
可以看到一个新的结构体需要被链接,老实说我觉得这种拓展方式有点容易忘记。 该结构体中主要用于指定附件的格式。然后renderpass就置空好了,除此之外没有别的什么变化。
3.渲染
来到最关键的渲染环节,动态渲染把原来的beginRenderpass,end~,和一堆什么description,dependency之类的简化成了就四个东西,attachmentInfo 附件信息 和 renderingInfo 渲染信息和起止命令。
和renderpass对象一样,cmd beginRendering就是用来启动一个所谓的“renderpass实例”的,所有绘制只能发生在这么一个实例中,在动态渲染之前,该职责就是由renderpass对象启用的,
提到这个就是要注意renderpass对象和renderpass实例不是一个东西。
这里比较关键的点在于,没有了renderpass对象带有的“隐式转换”和“隐式依赖”,我们就需要手动的改变需要用于呈现的图像的布局(color attachment 到 present)。在一个有附件描述的renderpass对象中,这一操作被其隐式执行。就是那个init layout 和 final layout,在renderpass对象结束时会自动完成转换的功能。
这里barrier的执行依赖顺序我认为有多解,不仅仅只能像我这样写,我这里是起止阶段都是color attachment output 的阶段,也就是在该阶段之后再转换图像布局。我认为哪怕在管线末尾在转换也是可以的。不转换的话验证层会报个小错。
void renderer::draw(int width, int height)
{// 一个存着当前帧所用资源的结构体auto& currentGroup = g_RenderFrameGroups[g_CurrentFrameIndex];auto& cmd = currentGroup.cmdBuffer;// 一成不变的等待g_Device.waitForFences(currentGroup.fInFlight, true, UINT64_MAX);g_Device.resetFences(currentGroup.fInFlight);cmd.reset();vk::CommandBufferBeginInfo begin_info{};currentGroup.cmdBuffer.begin(&begin_info);auto value = g_Device.acquireNextImageKHR(g_Swapchain, UINT64_MAX, currentGroup.sImageAcquired);if (value.result != vk::Result::eSuccess){std::cerr << "Failed to acquire swap image!" << std::endl;return;}uint32_t image_index = value.value;// 动态渲染附件信息vk::RenderingAttachmentInfo color_attachment{};color_attachment.imageLayout = vk::ImageLayout::eColorAttachmentOptimal;color_attachment.imageView = g_SwapchainImageViews[image_index];color_attachment.resolveMode = vk::ResolveModeFlagBits::eNone;color_attachment.loadOp = vk::AttachmentLoadOp::eClear;color_attachment.clearValue.color = { 0.2f, 0.2f, 0.2f, 1.0f };// 动态渲染信息vk::RenderingInfo rendering_info{};rendering_info.colorAttachmentCount = 1;rendering_info.pColorAttachments = &color_attachment;rendering_info.renderArea.extent.width = width;rendering_info.renderArea.extent.height = height;rendering_info.layerCount = 1;// 动态阶段配置vk::Viewport viewport{};viewport.maxDepth = 1.0f;viewport.width = width;viewport.height = height;vk::Rect2D scissor{};scissor.extent.width = width;scissor.extent.height = height;// 用来在渲染完后改变图像布局的barriervk::ImageMemoryBarrier image_barrier{};image_barrier.newLayout = vk::ImageLayout::ePresentSrcKHR;image_barrier.image = g_SwapchainImages[image_index];image_barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;image_barrier.subresourceRange.levelCount = 1;image_barrier.subresourceRange.layerCount = 1;// 命令序列cmd.beginRendering(rendering_info);cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, g_Pipeline);cmd.setViewportWithCount(viewport);cmd.setScissorWithCount(scissor);cmd.endRendering();cmd.pipelineBarrier(vk::PipelineStageFlagBits::eColorAttachmentOutput,vk::PipelineStageFlagBits::eColorAttachmentOutput,vk::DependencyFlagBits::eByRegion,nullptr,nullptr,image_barrier);cmd.end();// 以下是一成不变的提交呈现流程std::vector<vk::PipelineStageFlags> wait_stages = { vk::PipelineStageFlagBits::eColorAttachmentOutput };vk::SubmitInfo submit_info{};submit_info.commandBufferCount = 1;submit_info.pCommandBuffers = &cmd;submit_info.waitSemaphoreCount = 1;submit_info.pWaitSemaphores = ¤tGroup.sImageAcquired;submit_info.pWaitDstStageMask = wait_stages.data();submit_info.signalSemaphoreCount = 1;submit_info.pSignalSemaphores = ¤tGroup.sRenderCompleted;g_GraphicsQueue.submit(submit_info, currentGroup.fInFlight);vk::PresentInfoKHR present_info{};present_info.pSwapchains = &g_Swapchain;present_info.pImageIndices = &image_index;present_info.swapchainCount = 1;present_info.waitSemaphoreCount = 1;present_info.pWaitSemaphores = ¤tGroup.sRenderCompleted;auto result = g_PresentQueue.presentKHR(present_info);g_CurrentFrameIndex = (g_CurrentFrameIndex + 1) % g_MaxFramesInFlight;
}
其他
1.为什么是hpp
这不是我第一次谈论该使用h还是hpp(指的是C风格还是C++风格的vulkan写法)。在经历了长期的探索下,总结了目前为止发现的两者的优缺点。
C风格:优点:API层薄,速度快,较多库使用,比如vma就只有官方支持C风格;缺点:繁琐。
C++风格:优点:安全,简便,自动分发句柄。缺点:与一些库兼容不友好。
最后选择C++风格也只是因为懒得给每个结构体写上XXX_CREATE_INFO之类的,而且枚举类也更容易阅读。类型安全倒是其次,我经常干一些不安全的操作。
另外因为vk-bootstrap这个库采用的其实是C风格的写法,所以交互时不免地需要做一些转换,就比如:
std::vector<VkImage> i = sbr.value().get_images().value();
g_SwapchainImages = std::vector<vk::Image>(i.begin(), i.end());
就是说VkImage的vector不能直接变为vk::Image,所以只能重新塞到一个新的vector里面。不知道能不能协变逆变。
又或者:像这样的暴力写法,直接初始化一个默认结构体用来创建,在很多情况下其实相当有用。
g_PipelineLayout = g_Device.createPipelineLayout({});
另一个重要的点就是hpp支持自动初始化结构体的一部分,这样就可以选择性的赋值需要的部分,比如刚刚的:
vk::ImageMemoryBarrier image_barrier{};
image_barrier.newLayout = vk::ImageLayout::ePresentSrcKHR;
image_barrier.image = g_SwapchainImages[image_index];
image_barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
image_barrier.subresourceRange.levelCount = 1;
image_barrier.subresourceRange.layerCount = 1;
这里我只用到了我需要修改的部分,其余的保持默认初始化即可。然后可以多次链式访问字段,比如subresourceRange有很多子字段,又或者clearValue等经常有字段为0的结构体,可以省去一些心智负担。
2.动态编译着色器
与动态渲染无关,非必要但提一嘴,vulkan SDK里有很多好东西,shaderc就是其中一个,可以帮助我们直接动态编译glsl文件到spiv。
3.关于交换链重建
swapchain recreation 同样是一个非常常见的问题。即怎么让窗口参数发生变化后重建交换链,最常见的就是窗口尺寸发生变化后。如果没有重建交换链,就会有一大堆错误和警告。
在没有动态渲染前,重建交换链包含了重建交换链本身及其imageview图像视图,重建framebuffers帧缓冲区,必要时,重建renderpass。
如果使用动态渲染,则可以省去帧缓冲区的重建过程,虽然不算很复杂但也算省了。不过交换链还是得重建。
但是今天重点探讨的是重建的时机。所谓时机是指在什么情况下重建交换链这些资源是最合适的,当然这个问题有很多解决方案,我个人认为最直接的就是通过外部限制(同步)来进行安全的,比如:
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{switch (event->type){case SDL_EVENT_WINDOW_RESIZED:renderer::resize(event->window.data1, event->window.data2); break;case SDL_EVENT_WINDOW_MINIMIZED: isMinimized = true; break;case SDL_EVENT_WINDOW_RESTORED: isMinimized = false; break;case SDL_EVENT_QUIT: return SDL_APP_SUCCESS;}return SDL_APP_CONTINUE;
}
这是SDL3特有的回调式main入口写法,当然完全可以不用了解。只需要知道在这个处理事件(消息)循环的程序中在干什么就行了。比如这里当window resized 即窗口大小改变发生时,就执行resize函数,这个函数就是在重建交换链等资源,关于重建函数只有一个要点:记得waitIdle,即在设备空闲时再重建,传入的两个数据是宽高。而当窗口最小化或恢复时,我们修改对应的一个bool值,那么这个bool值有什么用呢?
SDL_AppResult SDL_AppIterate(void* appstate)
{int w, h;SDL_GetWindowSize(window, &w, &h);if (!isMinimized){renderer::draw(w, h);}return SDL_APP_CONTINUE;
}
当当当。对,就是这么简单粗暴,当窗口最小化时不进行渲染。因为交换链尺寸为0时同样是不可用的,所以它都最小化了我干嘛还渲染它呢。当然还有其他方法可以让交换链在背地里渲染,但是在相当多情况下,这应该是一个省心省力的选择。这个方法没有选择在出现异常时再进行处理,而是主动通过外部消息或事件预先对交换链等资源进行重建。官方的那个教程中是比较被动的去接收错误(异常),像什么out of date,suboptimal之类的。所以是否可认为这种交换链重建方法更加主动?不如就叫做主动重建和被动重建吧。
4.关于“在执行帧”
其实叫 Frame In Flight,在官方教程中用于优化draw call。主要内容是创建多组帧渲染资源,以使上一帧在GPU中执行时,下一帧在CPU中录制,从而减少CPU空闲时间,提高渲染效率。
话是这么说,不过自己测试下来,在场景不复杂(指白板)的情况下,提升似乎似有似无。
而且在执行帧的数量跟交换链的图像数量完全无关。交换链可以理解为控制图像显示的特殊队列。
而在执行帧只是为了让CPU跟GPU能加快效率,各干各的不等对方。
结语
额,等等,目前感觉用动态渲染帧率最大好像下降了大概20%,我得去看看什么情况。