为什么单个深度缓冲区足以满足此 vulkan 交换链渲染循环?

Why is a single depth buffer sufficient for this vulkan swapchain render loop?

我正在关注 https://vulkan-tutorial.com/ and at the depth buffering chapter 上的 vulkan 教程,作者 Alexander Overvoorde 提到 "We only need a single depth image, because only one draw operation is running at once." 这就是我的问题所在。

这几天看了很多关于 Vulkan 同步的 SO 问题和 articles/blog 帖子,但我似乎无法得出结论。目前我收集到的信息如下:

同一个子通道中的绘制调用在 gpu 上执行 好像它们是有序的,但前提是它们绘制到帧缓冲区(我不记得我在哪里读过这个,它可能是 youtube 上的技术谈话,所以我对此不是 100% 确定)。据我所知,这更多的是 GPU 硬件行为而不是 Vulkan 行为,所以这基本上意味着上述一般情况下是正确的(包括跨子通道甚至渲染通道)——这会回答我的问题,但我不能找不到任何关于此的明确信息。

我最接近回答我的问题的是这个 reddit comment OP 似乎接受了,但理由基于两件事:

我既没有看到任何高级队列刷新(除非有某种我在规范中终生找不到的明确的队列刷新),也没有看到渲染过程描述对其附件的依赖性 - 它描述了附件,但不是依赖项(至少不是明确的)。规范的相关章节看了好几遍,感觉语言不够清晰,初学者完全无法掌握。

我也非常感谢尽可能引用 Vulkan 规范。

编辑:澄清一下,最后一个问题是: 什么同步机制保证在当前绘制调用完成之前不提交下一个命令缓冲区中的绘制调用?

不,光栅化顺序不会(根据规范)扩展到单个子通道之外。如果多个子通道写入同一个深度缓冲区,那么它们之间应该有一个VkSubpassDependency。如果渲染通道之外的东西写入深度缓冲区,那么还应该有显式同步(通过屏障、信号量或栅栏)。

FWIW 我认为 vulkan 教程示例不符合要求。至少我没有看到任何可以防止深度缓冲区内存危险的东西。似乎深度缓冲区应该复制到 MAX_FRAMES_IN_FLIGHT,或者显式同步。

关于未定义行为的偷偷摸摸的部分是错误的代码通常可以正确运行。不幸的是,在验证层中制作同步证明有点棘手,所以现在唯一剩下的就是简单地小心。

面向未来的答案:
我看到的是带有 imageAvailablerenderFinished 信号量的传统 WSI 信号量链(与 vkAnquireNextImageKHRvkQueuePresentKHR 一起使用)。只有一个 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT 的子通道依赖关系链接到 imageAvailable 信号量。然后是带有 MAX_FRAMES_IN_FLIGHT == 2 的栅栏,以及保护各个交换链图像的栅栏。这意味着两个后续帧应该 运行 彼此畅通无阻(除非在极少数情况下它们获得相同的交换链图像)。因此,深度缓冲区似乎在两帧之间不受保护。

恐怕,我不得不说 Vulkan 教程是错误的。在目前的状态下,不能保证只使用一个单一的深度缓冲区时不存在内存危险。然而,它只需要一个非常小的改变,这样只有一个深度缓冲区就足够了。


让我们分析一下在drawFrame.

中执行的代码的相关步骤

我们有两个不同的队列:presentQueuegraphicsQueue,以及 MAX_FRAMES_IN_FLIGHT 个并发帧。我指的是 "in flight index" 和 cf(代表 currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT)。我使用 sem1sem2 表示不同的信号量数组,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 阶段等待上一个绘制在恢复之前调用完成。 IE。我们要创建以下场景:

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 中的命令需要屏障。 "barrier" 可以通过使用 subpass 依赖关系来建立:

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 类型的 subpass 依赖,我们可以确定 renderpass-external 命令是同步的(即与前一帧同步)。

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

是的,我也花了一些时间试图弄清楚“我们只需要一个深度图像,因为一次只有一个绘制操作是 运行”这句话的意思。

对于三重缓冲渲染设置,在达到 MAX_FRAMES_IN_FLIGHT 之前将工作提交到队列中,这对我来说没有意义 - 不能保证所有三个都不是 运行一次!

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