为什么在Vulkan交换链渲染循环中只需要一个深度缓冲区就足够了?

11

我正在按照https://vulkan-tutorial.com/上的Vulkan教程进行学习,到了深度缓冲章节时,作者Alexander Overvoorde提到:“我们只需要一个深度图像,因为一次只有一个绘制操作在运行。” 这就是我的问题所在。

过去几天我阅读了许多关于Vulkan同步的SO问题和文章/博客帖子,但似乎无法得出结论。 我目前收集到的信息如下:

相同subpass中的绘制调用在GPU上执行,就好像它们按顺序执行一样,但仅当它们绘制到帧缓冲区时才会这样(我记不清楚我在哪里读到的,可能是YouTube上的技术讲座,所以我对此并不100%确定)。 就我所理解的而言,这更多地是GPU硬件行为,而不是Vulkan行为,因此这基本上意味着上述内容通常是正确的(包括跨subpasses和甚至render passes)- 这将回答我的问题,但我找不到任何明确的信息。

我最接近得到答案的是这个reddit comment,但其基于以下两点:
  • "在高级别上有一个队列刷新,确保之前提交的渲染通道已完成"

  • "渲染通道本身描述了它们从哪些附件中读取和写入作为外部依赖项"

我既没有看到任何高级别队列刷新(除非有一些明确的队列刷新我在规范中找不到),也没有发现渲染通道描述其附件的依赖关系 - 它描述了附件,但没有描述依赖关系(至少没有明确说明)。我已经多次阅读了相关章节的规范,但感觉语言不够清晰,初学者很难完全掌握。
如有可能,请提供Vulkan规范引用。
编辑:澄清一下,最终问题是: 什么同步机制可以保证下一个命令缓冲区中的绘制调用在当前绘制调用完成之前不会被提交?

1
这不是问题的一部分,但我也很感激有关此类GPU行为的文章或更好的书籍的指针。我已经订购了Parminder Singh的Learning Vulkan和Graham Sellers的Vulkan Programming Guide,但它们还没有到货,似乎也没有太多关于GPU硬件的内容(我可能是错的)。不幸的是,我不喜欢Pawel Lapinski的Vulkan Cookbook的格式,据我所读,它是更好的选择之一 - 我更喜欢熟练掌握理论并自己完成工作,而不是遵循“食谱”。 - cluntraru
1
并不完全是这样。它解释说,在同一子通道中绘制的操作会按顺序执行,但是对于不同的子通道(或者几乎相同的命令缓冲区在此情况下提交两次),该怎么办呢?它提到这些由外部依赖关系控制,但是这里唯一的外部子通道依赖关系将用于与imageAcquired信号量同步,它没有srcAccessMask,并且因此实际上不应等待任何颜色附件阶段,因为它没有需要访问的资源(如果我错了,请纠正我)。 - cluntraru
1
在这种情况下,如果字符用尽了,那么以下的drawFrame()调用不会同步运行,会导致类似于:渲染1开始,然后渲染2开始,然后渲染1完成,然后呈现1开始,然后渲染2完成等等。在这种情况下,深度缓冲区将被重用并且可能会出现错误。 - cluntraru
1
@cluntraru:回答你的问题需要分析相关教程代码的依赖图,这是一个相当大的要求。我不知道是否有明确的事件或信号量等待来防止帧之间的重叠。我不知道这是否正确,但我也不知道它是否不正确。找出答案的唯一方法就是阅读整个教程的代码。 - Nicol Bolas
Vulkan是一种显式API。在Vulkan中,你几乎总是需要显式同步。如果你不小心把手放进正在运转的割草机里,那么你可以用一只手数出例外情况。根据我提供的Q链接,在单个子通道中,有光栅化顺序,因为同步vkDraw*会变得非常麻烦。其他所有内容都必须显式同步。你的应用程序示例要么正确地执行了显式同步,要么不符合规范。无论哪种情况,这都与问题主题无关。 - krOoze
显示剩余7条评论
3个回答

14
很抱歉,我不得不说Vulkan教程是错误的。在当前状态下,不能保证仅使用一个深度缓冲区时没有内存危险。然而,只需进行非常小的更改,就可以确保仅需要一个深度缓冲区。
让我们分析在drawFrame中执行的相关代码步骤。
我们有两个不同的队列:presentQueue和graphicsQueue,以及MAX_FRAMES_IN_FLIGHT并发帧。我用cf(表示currentFrame =(currentFrame + 1)%MAX_FRAMES_IN_FLIGHT)来引用“in flight index”。我使用sem1和sem2来表示不同的信号量数组,fence用于存储栅栏的数组。
伪代码中的相关步骤如下:
vkWaitForFences(..., fence[cf], ...);
vkAcquireNextImageKHR(..., /* signal when done: */ sem1[cf], ...);
vkResetFences(..., fence[cf]);
vkQueueSubmit(graphicsQueue, ...
    /* wait for: */ sem1[cf], /* wait stage: *, COLOR_ATTACHMENT_OUTPUT ...
    vkCmdBeginRenderPass(cb[cf], ...);
      Subpass Dependency between EXTERNAL -> 0:
          srcStages = COLOR_ATTACHMENT_OUTPUT,
          srcAccess = 0, 
          dstStages = COLOR_ATTACHMENT_OUTPUT,
          dstAccess = COLOR_ATTACHMENT_WRITE
      ...
      vkCmdDrawIndexed(cb[cf], ...);
      (Implicit!) Subpass Dependency between 0 -> EXTERNAL:
          srcStages = ALL_COMMANDS,
          srcAccess = COLOR_ATTACHMENT_WRITE|DEPTH_STENCIL_WRITE, 
          dstStages = BOTTOM_OF_PIPE,
          dstAccess = 0
    vkCmdEndRenderPass(cb[cf]);
    /* signal when done: */ sem2[cf], ...
    /* signal when done: */ fence[cf]
);
vkQueuePresent(presentQueue, ... /* wait for: */ sem2[cf], ...);

绘制调用是在一个队列上执行的:即graphicsQueue。我们必须检查在这个graphicsQueue上的命令是否可以理论上重叠。
让我们按照时间顺序考虑前两帧在graphicsQueue上发生的事件:
img[0] -> sem1[0] signal -> t|...|ef|fs|lf|co|b -> sem2[0] signal, fence[0] signal
img[1] -> sem1[1] signal -> t|...|ef|fs|lf|co|b -> sem2[1] signal, fence[1] signal

其中,t|...|ef|fs|lf|co|b 代表通过的不同管线阶段的绘制调用:

  • t ... TOP_OF_PIPE
  • ef ... EARLY_FRAGMENT_TESTS
  • fs ... FRAGMENT_SHADER
  • lf ... LATE_FRAGMENT_TESTS
  • co ... COLOR_ATTACHMENT_OUTPUT
  • b ... BOTTOM_OF_PIPE

虽然sem2[i] signal -> presentsem1[i+1]之间可能存在隐式依赖关系,但这仅适用于交换链提供一个图像(或者总是提供相同的图像)的情况。在一般情况下,不能假定这一点。也就是说,在第一个帧交给present后,没有任何东西会延迟后续帧的立即进展。栅栏也无法帮助,因为在fence[i] signal之后,代码会等待fence[i+1],也就是说,在一般情况下,这也无法阻止后续帧的进展。

我的意思是:第二帧开始与第一帧并发渲染,并且在我看来没有任何东西可以防止它同时访问深度缓冲区。


修复方法:

如果我们只想使用一个深度缓冲区,我们可以修复教程中的代码:我们想要实现的是,在恢复之前,eflf阶段等待上一个绘制调用完成。也就是说,我们想要创建以下情况:

img[0] -> sem1[0] signal -> t|...|ef|fs|lf|co|b -> sem2[0] signal, fence[0] signal
img[1] -> sem1[1] signal -> t|...|________|ef|fs|lf|co|b -> sem2[1] signal, fence[1] signal

_ 表示等待操作。

为了实现这一点,我们需要添加一个屏障,防止后续帧在同一时间执行 EARLY_FRAGMENT_TESTLATE_FRAGMENT_TEST 阶段。只有一个队列用于执行绘图调用,因此只有 graphicsQueue 中的命令需要屏障。可以使用子通道依赖性来建立“屏障”:

vkWaitForFences(..., fence[cf], ...);
vkAcquireNextImageKHR(..., /* signal when done: */ sem1[cf], ...);
vkResetFences(..., fence[cf]);
vkQueueSubmit(graphicsQueue, ...
    /* wait for: */ sem1[cf], /* wait stage: *, EARLY_FRAGMENT_TEST...
    vkCmdBeginRenderPass(cb[cf], ...);
      Subpass Dependency between EXTERNAL -> 0:
          srcStages = EARLY_FRAGMENT_TEST|LATE_FRAGMENT_TEST,
          srcAccess = DEPTH_STENCIL_ATTACHMENT_WRITE, 
          dstStages = EARLY_FRAGMENT_TEST|LATE_FRAGMENT_TEST,
          dstAccess = DEPTH_STENCIL_ATTACHMENT_WRITE|DEPTH_STENCIL_ATTACHMENT_READ
      ...
      vkCmdDrawIndexed(cb[cf], ...);
      (Implicit!) Subpass Dependency between 0 -> EXTERNAL:
          srcStages = ALL_COMMANDS,
          srcAccess = COLOR_ATTACHMENT_WRITE|DEPTH_STENCIL_WRITE, 
          dstStages = BOTTOM_OF_PIPE,
          dstAccess = 0
    vkCmdEndRenderPass(cb[cf]);
    /* signal when done: */ sem2[cf], ...
    /* signal when done: */ fence[cf]
);
vkQueuePresent(presentQueue, ... /* wait for: */ sem2[cf], ...);

这将在不同帧的绘制调用之间为graphicsQueue建立适当的屏障。因为它是一个EXTERNAL -> 0类型的子通道依赖关系,我们可以确保renderpass-external命令已经被同步(即与上一帧同步)。

更新:此外,sem1 [cf]的等待阶段必须从COLOR_ATTACHMENT_OUTPUT更改为EARLY_FRAGMENT_TEST。这是因为布局转换发生在vkCmdBeginRenderPass时间:在第一个同步范围(srcStagessrcAccess)之后,在第二个同步范围(dstStagesdstAccess)之前。因此,交换链图像必须已经可用,以便布局转换在正确的时间点发生。


感谢您提供完整的答案!我认为值得澄清的是,旧的子通道依赖关系并未被删除(仍然需要信号量同步),现在有两个从EXTERNAL到0,只是为了避免其他人阅读时可能产生的混淆。此外,在修复中,dstAccess是否应该包括DEPTH_STENCIL_ATTACHMENT_READ,而不仅仅是WRITE? - cluntraru
我所指的两个依赖项是COLOR_ATTACHMENT_OUTPUT和您刚添加的那个(隐式的0-> EXTERNAL将是第三个,不计算我所说的)。旧的仍然是必需的,因此颜色附件上的布局转换会在您提到的信号量等待后发生。是的,颜色附件阶段会等待信号量,但布局转换会在依赖项的源和目标之间指定的点发生。顺便说一下,如果有两个EXTERNAL-> 0依赖项,哪一个用于确定转换何时发生? - cluntraru
哦,我明白了。布局转换会在vkCmdBeginRenderPass时发生,对吧?而且vkCmdBeginRenderPass不会在之前的EARLY_FRAGMENT_TEST|LATE_FRAGMENT_TEST阶段完成之前发生。我认为,最好的方法可能是将信号量的等待阶段更改为:/* wait for: */ sem1[cf], /* wait stage: *, EARLY_FRAGMENT_TEST - j00hi
@j00hi 难道每个交换链图像都有一个深度缓冲图像不是更简单的解决方案吗? - Daniel Marques
1
@DanielMarques 当然这也是一个选项——甚至在理论上允许更多的并行化可能是一个不错的选择。只是问题是如何使用单一深度缓冲区,这在极度内存有限的情况下可能会具有优势。 - j00hi
显示剩余15条评论

3
不,光栅化顺序规范并不超出单个子通道。如果多个子通道写入同一深度缓冲区,则它们之间应该有一个“VkSubpassDependency”。如果渲染通道外的某些东西写入深度缓冲区,则还应该有明确的同步(通过障碍、信号量或栅栏)。
顺便说一下,我认为vulkan-tutorial示例不符合规范。至少我没有看到任何防止深度缓冲区上发生内存危险的东西。似乎应该将深度缓冲区复制到“MAX_FRAMES_IN_FLIGHT”,或者显式同步。
关于未定义行为的诡异部分在于错误代码通常可以正常工作。不幸的是,在验证层中进行同步证明有点棘手,因此现在唯一剩下的就是要小心谨慎。
未来的答案: 我看到的是常规的WSI信号量链(与“vkAnquireNextImageKHR”和“vkQueuePresentKHR”一起使用),具有“imageAvailable”和“renderFinished”信号量。只有一个子通道依赖项与“VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT”链接,该依赖项链接到“imageAvailable”信号量。然后有一个具有“MAX_FRAMES_IN_FLIGHT == 2”的栅栏,以及保护各个交换链图像的栅栏。这意味着两个连续的帧应该在彼此之间无障碍运行(除非它们获取相同的交换链图像)。因此,深度缓冲区似乎在两个帧之间没有受到保护。

这也是我怀疑的,所以才发了这个问题。谢谢你花时间查看代码,我知道这很难要求。 - cluntraru

2

是的,我也花了一些时间去理解这个陈述的含义:“我们只需要一个深度图像,因为一次只会运行一个绘制操作。”

在三重缓冲渲染设置中,当将工作提交到队列中直到达到MAX_FRAMES_IN_FLIGHT时,我不明白这样说的意义 - 不能保证这三个都没有同时运行!

虽然单个深度图像可以正常工作,但每个帧使用完全独立的资源集(块和全部)来复制所有内容似乎是最安全的设计,并且在测试下产生了相同的性能。


“在更复杂的渲染场景中,“一切都三倍化…”的设计在内存方面表现如何?比如多通道渲染用于级联阴影,例如你有4个分层深度纹理…这可行吗?” - undefined
非常好的问题!自从发布以来,我的渲染引擎已经有了一些进展 - 现在我已经实现了基于链表的OIT透明度,并且可以处理三到四层全屏透明度,需要大约256MB的内存。我肯定不会再复制那个...目前,使用全3D PBR和OIT透明度工作流以及2D正交投影,我需要大约665MB的图像缓冲区。不过,我正在考虑将帧生成的缓冲区减少到双缓冲,但保持三缓冲的呈现方式。还没有实现级联阴影映射,下一步就是它们了。 :) - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接