那些VulkanTutorial没说的

发布于:1775991766634 | 修改于:1775992029819 | 分类: 技术

大概在去年的这个时候,我刚过了下vulkan tutorial,于是开始构思我的下一个toy project,用vulkan写个渲染器 但在动手之前,我需要解决几个在教程中困惑了我很久的问题。 # 三份资源? 我从swapchain里掏出来了三张imageView,然后....哇,难道所有资源都要建立三份?我能不能不要三缓冲? 首先是为什么要建立多份资源。 ## 1. 防止cpu和gpu竞争 例子:我创建了一块host shared memory作为ubo,我在第n帧送入了一些数据,送入渲染的同时开始第n+1帧,并且在n+1帧又送入数据覆盖了这个ubo 此时第n帧的渲染与n+1帧重叠,gpu上读取到的数据被cpu送入的n+1帧数据污染,造成了竞争。 这部分的资源代表就是VkBuffer, VkCommandbuffer ## 2. gpu和gpu的竞争 假设有n n+1 n+2三帧数据,n+1和n+2被先后submit到了队列中。 这个时候,n+1和n+2在gpu上并不是串行的,而是并行在进行渲染,甚至可能出现后提交的n+2比n+1早渲染完。Vulkan本身并不保证一个队列里的提交完成的先后。 这能保证gpu的最大化利用,但对开发来说绝对不太友好。 这部分的资源代表是VkImage/VkImageView,VkFramebuffer,VkBuffer ## 怎么解决 这种正在被渲染,或者正在被录制的帧,被称作FrameInFlight,简称FiF。对于vk教程那种,就是3FiF的情况。 3Fif可能出现的情况很多: - n待录制,n+1在录制,n+2在渲染 - n在录制,n+1在渲染,n+2在渲染 - n, n+1, n+2都在渲染 每FIF建立一份自己的资源当然可以! 但是有种情况需要考虑:有些效果需要n-1帧的计算结果,竞态依然会发生,这种情况我们就需要记录下资源状态了,然后放置cmdBarrier了。但这就仅限于同一Queue的资源同步,对于跨队列的,恐怕还得复杂点解决...我还没研究过 其实可以做点简化,只有2FiF,并且保证一帧录制,一帧渲染 这样可以不再担心gpu上的竞争,只要考虑cpu和gpu的竞争就行。 这是我现在的做法,不用多份资源,关键资源只要建立一份。但对于UBO和swapchain的FrameBuffer,CommandBuffer,依旧需要2份 这种做法在Doom eternal上得到应用,并且Doom的图程说:这么干并不比3FIF差 ## UBO简化 事实上UBO也可以只要一份 利用这个特性: Dynamic Uniform Buffer 此特性允许你在绑定buffer到descriptorSet的时候,用一个offset对buffer做偏移,使得shader中能访问到buffer+offet处的数据作为UniformBuffer 在这个特性下,buffer数量大大降低,完全可以创建一个大buffer,然后在内部分配内存,作为uniform。 同时也带来一个好处。对于高频更新的数据,比如物体的transform,在之前的实现需要创建多份以防止GPU和CPU的竞态;但利用这个特性,我们可以做一个RingBuffer,采取线性分配;每当n+FIF帧开始的时候,释放第n帧的数据(标记一下队头啦),十分好管理。 但需要注意一下设备会有UBO数据内存对齐的要求..... # layout转换?Barrier? Vulkan另一个令我头痛的是图像layout问题。 一个image要做RenderTarget需要转换到VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 如果是深度image则需要转换到VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, 假如这个RenderPass不要写入深度,那还可以选择VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL, 这个image在做完RT后还需要被Shader读取,于是又得切换到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; 此时这一帧渲染好了,又得切回VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,再来一遍.... 我相信每个人都遇到过这个头疼的烦恼,不知道当前image是什么layout,不知道RenderPass把image自动转成啥玩意儿了,不知道barrier该怎么填写 当然可以用懒狗指定layout:VK_IMAGE_LAYOUT_GENERAL;或者是vk1.5的新拓展(叫啥我忘了,反正有 但是就兼容性来说,我们需要一个更好的方案。 先来说barrier,ta的功能其实就是刷新下gpu上的数据的可见性,和它在cpu上的亲戚差不多,坏处是会造成bubble(毫无疑问),而且不能在renderpass内部放置。 一个BufferMemoryBarrier如下: ```cpp typedef struct VkBufferMemoryBarrier { VkStructureType sType; const void* pNext; VkAccessFlags srcAccessMask; VkAccessFlags dstAccessMask; uint32_t srcQueueFamilyIndex; uint32_t dstQueueFamilyIndex; VkBuffer buffer; VkDeviceSize offset; VkDeviceSize size; } VkBufferMemoryBarrier; ``` 其参数对于初学者来说有些难以理解,但如果把它和它在cpu上咱见多了的barrier一对照来理解,其实差不多,不过就是需要自己指定流水线上的位置罢了。 先讲一下cpu上的barrier, 因为cpu和编译器的优化,编写的代码并不总是按照编写的顺序进行执行,这就是指令重排 为了防止这种重排导致的不确定,我们需要加barrier来保证其内存可见性 内存可见性可以理解为 ‘让所有读(或者写)操作到这里全部生效!’ 对于Vulkan也有这种情景,比如我们从buffer a向buffer b中复制了一些数据,buffer b会被shader读取,我们需要保证shader执行的时候buffer b的内存可见性(或者说写入已经完成了),这时候我们就需要插入一个barrier。 `srcAccessMask`指明是什么步骤导致的读/写,比如说是`VK_ACCESS_TRANSFER_WRITE_BIT`;`dstAccessMask`指明的是在执行这个步骤前需要保证内存的可见性,比如`VK_ACCESS_SHADER_READ_BIT`。两个和Queue相关的参数是用来做Queue之间的所有权交换的。offset和size则是指定刷新内存可见性的范围。 接着是录制命令部分: `vkCmdPipelineBarrier)(VkCommandBuffer commandBuffer, VkPipelineStageFlags srcStageMask, VkPipelineStageFlags dstStageMask, VkDependencyFlags dependencyFlags, uint32_t memoryBarrierCount, const VkMemoryBarrier* pMemoryBarriers, uint32_t bufferMemoryBarrierCount, const VkBufferMemoryBarrier* pBufferMemoryBarriers, uint32_t imageMemoryBarrierCount, const VkImageMemoryBarrier* pImageMemoryBarriers);` 又出现了两个新参数需要关注`VkPipelineStageFlags srcStageMask, VkPipelineStageFlags dstStageMask`,这两个指定的是什么阶段需要保证可见性。 不知道你是否和我有过同样的疑问,为什么指定了Access还要再指定Stage呢? 举个例子,`VK_ACCESS_SHADER_READ_BIT`可能发生在`VK_PIPELINE_STAGE_VERTEX_SHADER_BIT`,也可能发生在`VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT`,所以需要更细粒度的控制。 理解了BufferMemoryBarrier就好理解ImageMemoryBarrier了: ```cpp typedef struct VkImageMemoryBarrier { VkStructureType sType; const void* pNext; VkAccessFlags srcAccessMask; VkAccessFlags dstAccessMask; VkImageLayout oldLayout; VkImageLayout newLayout; uint32_t srcQueueFamilyIndex; uint32_t dstQueueFamilyIndex; VkImage image; VkImageSubresourceRange subresourceRange; } VkImageMemoryBarrier; ``` 保证在以oldLayout过完了srcStageMask的srcAccessMask后,要在dstStageMask的dstAccessMask前转换为newLayout 更加烦的问题来了:我tm怎么知道现在的buffer/image是个什么状态,我转来转去,RenderPass还有隐式状态转换;你驱动内部肯定门清,凭什么要我干这事情...... 关于RenderPass自动转换layout这块,我的评价是不如不要这个功能,拉完了,徒增心智负担。 我的建议是咱就默认下,color attachment送进去,出来就是VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, depth stencil出来就是VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_STENCIL_READ_ONLY_OPTIMAL, 想干其他什么自己放barrier转换就完事了,这样处理起来简单。 在vk1.4转正的dynamic rendering就干了差不多的事情,我觉得蛮好 ## Barrier放不过来了? 在VK教程中,教程作者的代码靠着放他妈狗屁的指导思想,写出了如下丧尽天良的代码: ```cpp VkPipelineStageFlags sourceStage; VkPipelineStageFlags destinationStage; if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) { barrier.srcAccessMask = 0; barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; destinationStage = VK_PIPELINE_STAGE_TRANSFER_BIT; } else if (oldLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL && newLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) { barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; sourceStage = VK_PIPELINE_STAGE_TRANSFER_BIT; destinationStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; } else { throw std::invalid_argument("unsupported layout transition!"); } vkCmdPipelineBarrier( commandBuffer, sourceStage, destinationStage, 0, 0, nullptr, 0, nullptr, 1, &barrier ); ``` 我看到代码的时候人是蒙的,难道我得穷举完所有的情况么.... 对于每个oldlayout和newlayout的组合,我都要写特定的代码?搞笑吧 最开始的渲染器实现中我也做了差不多的事情 我当时的做法是: 在每个RenderPass的结束,会根据Image的Usage去转换ImageLayout;比如ImageUsage有Sampled一项,那么我就转换到Shader_read_only_layout,假如有TransferSrc我就转换到transfer_src_layout 后面我发现,这么干太难了,太多状态需要枚举,可能会出现不必要的barrier。以至于后面我完全看不懂Layout的流向了。。。 于是我想 - 我能不能在资源要用的时候再转换Layout呢/刷新可见性呢? 于是我把资源中储存的layout换成了一个经过了抽象的ResourceState ```cpp enum class ResourceState : uint64_t { // ========================================== // Initial State // ========================================== Common = 0, // Undefined // ========================================== // For Buffer // ========================================== VertexBuffer = 1ULL << 0, // Read as Vertex Buffer IndexBuffer = 1ULL << 1, // Read as Index Buffer UniformBuffer = 1ULL << 2, // Read as Uniform Buffer IndirectArgument = 1ULL << 3, // Read as Indirect Buffer // ========================================== // Image & Buffer // ========================================== ShaderResource = 1ULL << 4, // Shader Read (Sampled Image / Texture) UnorderedAccess = 1ULL << 5, // Shader Read/Write (Storage Image / Storage Buffer / UAV) ..... } ``` 然后建立了一个ResourceState和Stage/AccessMask的对应关系: ```cpp struct VulkanStateMapping { VkPipelineStageFlags stageMask; VkAccessFlags accessMask; VkImageLayout imageLayout; }; VulkanStateMapping getVulkanMapping(ResourceState state) { switch (state) { case ResourceState::Common: // Top of pipe : NO BLOCK return { VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, 0, VK_IMAGE_LAYOUT_UNDEFINED }; // ========================================== // BUFFER // ========================================== case ResourceState::VertexBuffer: return { VK_PIPELINE_STAGE_VERTEX_INPUT_BIT, VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT, VK_IMAGE_LAYOUT_UNDEFINED }; case ResourceState::IndexBuffer: return { VK_PIPELINE_STAGE_VERTEX_INPUT_BIT, VK_ACCESS_INDEX_READ_BIT, VK_IMAGE_LAYOUT_UNDEFINED }; case ResourceState::UniformBuffer: // UBO read may happen at any time during shading return { VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_UNIFORM_READ_BIT, VK_IMAGE_LAYOUT_UNDEFINED }; ...... } } ``` 建立一个函数,用于改变resourceState,自动根据当前的状态和目标状态自动放置Barrier。 注意:Barrier不能再RenderPassBegin和End之间放置。 还有一个问题是:image是可以有很多切片的,我有时候只要改变某一部分的image的layout,而其他不变( 比如更新image的某个mipmap ),这导致image的状态需要更加精细的管理;我的做法是对每个mip每个arraylayer跟踪状态。 # swapchain重建 > 这部分是献给刚学时本币的我:-> 在窗口大小改变的时候,需要重建Swapchain,因为Image是从swapchain里掏出来的,所以对应的ImageView也得重建 当时我理解为了要重建所有framebuffer和RenderPass, 后来我才发现,完全可以保持其他的FrameBuffer大小不变,然后donw/up sample到swapchain的framebuffer上.....