我在https://vulkan-tutorial.com/在深度缓冲章节,作者Alexander Overvoorde提到"我们只需要一个深度图像,因为一次只运行一个绘制操作。"这就是我的问题所在。
在过去的几天里,我读了很多关于Vulkan同步的SO问题和文章/博客文章,但我似乎无法得出结论。到目前为止,我收集到的信息如下:
同一子路径中的Draw调用在gpu上执行,就好像它们是按顺序执行的一样,但前提是它们绘制到帧缓冲区(我记不起我在哪里读到这篇文章了,这可能是youtube上的一篇技术演讲,所以我不能100%确定)。据我所知,这更多的是GPU硬件行为,而不是Vulkan行为,因此这本质上意味着上述情况在一般情况下是正确的(包括子通道甚至渲染过程)——这将回答我的问题,但我找不到任何关于这方面的明确信息。
我最接近于回答我的问题的是这条reddit评论,OP似乎接受了这条评论,但理由是基于两件事:
-
"有一个高级别的队列刷新,确保之前提交的渲染过程完成">
-
"呈现过程本身将它们从哪些附件读取和写入的附件描述为外部依赖项">
我既没有看到任何高级队列刷新(除非有某种明确的队列刷新,我在规范中找不到),也没有看到渲染过程描述其附件上的依赖关系——它描述了附件,但没有描述依赖关系(至少没有明确)。我已经多次阅读了规范的相关章节,但我觉得语言不够清晰,初学者无法完全掌握。
如果可能的话,我也非常感谢Vulkan规范的报价。
编辑:为了澄清,最后一个问题是:什么同步机制可以保证在当前绘制调用完成之前不会提交下一个命令缓冲区中的绘制调用?
恐怕,我不得不说Vulkan教程是错误的。在其当前状态下,不能保证仅使用一个深度缓冲区时没有内存危险。然而,它只需要非常小的改变,因此只有一个深度缓冲区就足够了。
让我们分析在drawFrame
中执行的代码的相关步骤。
我们有两个不同的队列:presentQueue
和graphicsQueue
,以及MAX_FRAMES_IN_FLIGHT
并发帧。我用cf
(代表currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT
)来指代"飞行中指数"。我用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], ...);
draw调用在一个队列上执行: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
代表不同的流水线阶段,一个draw调用通过:
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 -> present
和sem1[i+1]
之间可能存在隐式依赖关系,但这仅适用于交换链仅提供一个映像的情况(或始终提供相同映像的情况)。在一般情况下,这是不能假设的。这意味着,在第一帧被移交给present
之后,没有任何东西会延迟后续帧的立即进程。围栏也没有帮助,因为在fence[i] signal
之后,代码在fence[i+1]
上等待,即在一般情况下,这也不会阻止后续帧的前进。
我的意思是:第二帧开始同时渲染到第一帧,据我所知,没有什么可以阻止它同时访问深度缓冲区。
修复:
不过,如果我们只想使用一个深度缓冲区,我们可以修复教程的代码:我们想要实现的是ef
和lf
阶段等待上一次绘制调用完成后再继续。也就是说,我们想要创建以下场景:
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
其中CCD_ 33指示等待操作。
为了实现这一点,我们必须添加一个屏障,以防止后续帧同时执行EARLY_FRAGMENT_TEST
和LATE_FRAGMENT_TEST
阶段。执行draw调用的队列只有一个,因此只有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外部命令是同步的(即与前一帧同步)。
更新:此外,sem1[cf]
的等待阶段必须从COLOR_ATTACHMENT_OUTPUT
更改为EARLY_FRAGMENT_TEST
。这是因为布局转换发生在vkCmdBeginRenderPass
时间:在第一同步作用域(srcStages
和srcAccess
)之后和第二同步作用域之前(dstStages
和dstAccess
)。因此,交换链图像必须已经在那里可用,以便在正确的时间点进行布局转换。
否,光栅化顺序不会(根据规范)扩展到单个子路径之外。如果多个子进程写入同一深度缓冲区,那么它们之间应该有一个VkSubpassDependency
。如果渲染过程之外的东西写入深度缓冲区,那么也应该有显式同步(通过屏障、信号量或围栏)。
FWIW我认为vulkan教程示例不符合要求。至少我看不到任何可以防止深度缓冲区内存危险的东西。似乎应该将深度缓冲区复制到MAX_FRAMES_IN_FLIGHT
,或者显式同步。
未定义行为的狡猾之处在于,错误的代码通常能正确工作。不幸的是,在验证层中进行同步证明有点棘手,所以目前唯一剩下的就是要小心。
未来的答案是:
我看到的是带有imageAvailable
和renderFinished
信号量的传统WSI信号量链(与vkAnquireNextImageKHR
和vkQueuePresentKHR
一起使用)。VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT
只有一个子通路依赖关系,它被链接到imageAvailable
信号量。然后是带有MAX_FRAMES_IN_FLIGHT == 2
的围栏,以及保护单个交换链图像的围栏。这意味着两个后续帧应该不受阻碍地相互写入(除非在极少数情况下它们获得相同的交换链图像)。因此,深度缓冲区在两帧之间似乎没有受到保护。
是的,我也花了一些时间试图弄清楚这句话的意思"我们只需要一个深度图像,因为一次只运行一个绘制操作">
这对我来说没有意义,因为在三缓冲渲染设置中,工作被提交到队列,直到达到MAX_FRAMES_IN_FLIGHT——不能保证这三个都不会同时运行!
虽然单个深度图像工作正常,但将所有内容增加三倍,使每帧使用一组完全独立的资源(块和所有)似乎是最安全的设计,并在测试中产生相同的性能。