何时可以安全地重写和重用 MTLBuffer 或其他 Metal 顶点缓冲区?

Posted

技术标签:

【中文标题】何时可以安全地重写和重用 MTLBuffer 或其他 Metal 顶点缓冲区?【英文标题】:When is it safe to write over and reuse a MTLBuffer or other Metal vertex buffer? 【发布时间】:2020-02-01 20:24:42 【问题描述】:

我刚开始接触 Metal,在掌握一些基本知识方面遇到了困难。我一直在阅读一大堆有关 Metal 的网页,并通过 Apple 的示例进行工作,等等,但我的理解仍然存在差距。我认为我的关键点是:处理顶点缓冲区的正确方法是什么,我怎么知道何时可以安全地重用它们?正如我将在下面描述的那样,这种困惑以多种方式表现出来,也许我的困惑的这些不同表现需要以不同的方式解决。

更具体地说,我在 macOS 上使用 Objective-C 中的 MTKView 子类来显示非常简单的 2D 形状:视图的整体框架,内部带有背景颜色,整个框架内部的 0+ 个矩形子框架带有它们内部有不同的背景颜色,然后是每个子帧内有 0+ 个各种颜色的平阴影方块。我的顶点函数只是一个简单的坐标变换,我的片段函数只是通过它接收到的颜色,基于苹果的三角形演示应用程序。对于带有单个正方形的单个子帧,我可以正常工作。到目前为止一切顺利。

有几件事让我感到困惑。

一:我可以设计我的代码以使用单个顶点缓冲区和对drawPrimitives: 的单个调用来渲染整个事物,从而在一次大爆炸中绘制所有(子)帧和正方形。然而,这并不是最优的,因为它破坏了我的代码的封装,其中每个子帧代表一个对象的状态(包含 0+ 个正方形的东西);我想让每个对象负责绘制自己的内容。因此,最好让每个对象设置一个顶点缓冲区并进行自己的drawPrimitives: 调用。但由于对象将按顺序绘制(这是一个单线程应用程序),我想在所有这些绘制操作中重用相同的顶点缓冲区,而不是让每个对象都必须分配并拥有一个单独的顶点缓冲区。但我可以这样做吗?在我调用drawPrimitives: 之后,我猜顶点缓冲区的内容必须被复制到 GPU,并且我假设(?)这不是同步完成的,所以立即开始修改顶点缓冲区是不安全的用于下一个对象的绘图。那么:我如何知道 Metal 何时使用完缓冲区,我可以再次开始修改它?

二:即使#1 有一个明确的答案,这样我可以阻塞直到 Metal 完成缓冲区,然后开始修改它以进行下一次 drawPrimitives: 调用,这是一个合理的设计吗?我想这意味着我的 CPU 线程会反复阻塞以等待内存传输,这不是很好。那么这是否将我推向了每个对象都有自己的顶点缓冲区的设计?

三:好的,假设每个对象都有自己的顶点缓冲区,或者我用一个大顶点缓冲区对整个事物进行一次“大爆炸”渲染(我认为这个问题适用于两种设计)。在我在命令缓冲区上调用presentDrawable:commit 之后,我的应用程序将关闭并做一些工作,然后尝试更新显示,所以我的绘图代码现在再次执行。我想重用之前分配的顶点缓冲区,覆盖其中的数据以进行新的更新显示。但再说一遍:我怎么知道什么时候安全?据我了解,commit 返回到我的代码这一事实并不意味着 Metal 已完成将我的顶点缓冲区复制到 GPU,在一般情况下,我不得不假设这可能需要任意长时间,所以它当我重新输入我的绘图代码时,可能还没有完成。正确的表达方式是什么?再说一遍:我应该阻止等待它们可用(但是我应该这样做),还是应该有第二组顶点缓冲区,以防 Metal 仍然忙于第一组? (这似乎只是将问题推到了问题上,因为当我为 third 更新输入我的绘图代码时,以前使用的缓冲区集可能还不可用,对吧?那么我可以添加第三组顶点缓冲区,但随后第四更新...)

四:对于绘制框架和子框架,我只想编写一个每个人都可以调用的可重用“drawFrame”类型的函数,但我对正确的设计有点困惑。使用 OpenGL,这很容易:

- (void)drawViewFrameInBounds:(NSRect)bounds

    int ox = (int)bounds.origin.x, oy = (int)bounds.origin.y;

    glColor3f(0.77f, 0.77f, 0.77f);
    glRecti(ox, oy, ox + 1, oy + (int)bounds.size.height);
    glRecti(ox + 1, oy, ox + (int)bounds.size.width - 1, oy + 1);
    glRecti(ox + (int)bounds.size.width - 1, oy, ox + (int)bounds.size.width, oy + (int)bounds.size.height);
    glRecti(ox + 1, oy + (int)bounds.size.height - 1, ox + (int)bounds.size.width - 1, oy + (int)bounds.size.height);

但是对于 Metal,我不确定什么是好的设计。我猜这个函数不能只是将它自己的小顶点缓冲区声明为一个本地静态数组,它会在其中抛出顶点然后调用drawPrimitives:,因为如果它连续调用两次,Metal 可能还没有复制顶点当第二次调用想要修改缓冲区时,来自第一次调用的数据。我显然不想在每次调用函数时都分配一个新的顶点缓冲区。我可以让调用者传入一个顶点缓冲区以供函数使用,但这只会将问题推到一个层次;那么调用者应该如何处理这种情况呢?也许我可以让函数将新顶点附加到调用者提供的缓冲区中不断增长的顶点列表的末尾;但这似乎要么迫使整个渲染完全预先计划好(这样我就可以预先分配一个大小合适的大缓冲区以适应每个人将要绘制的所有顶点——这需要***绘图代码以某种方式知道如何每个对象最终都会生成许多顶点,这违反了封装),或者做一个设计,我有一个扩展的顶点缓冲区,当它的容量证明不足时,它会根据需要重新分配。我知道如何做这些事情;但他们都感觉不对。我正在为正确的设计而苦苦挣扎,因为我认为我对 Metal 的内存模型还不够了解。有什么建议吗?对于这个很长的多部分问题,我深表歉意,但我认为所有这些都归咎于同样的基本缺乏理解。

【问题讨论】:

【参考方案1】:

对您的基本问题的简短回答是:在命令缓冲区完成之前,您不应覆盖添加到命令缓冲区的命令所使用的资源。确定这一点的最佳方法是添加完成处理程序。您也可以轮询命令缓冲区的status 属性,但这不是很好。

首先,在您提交命令缓冲区之前,不会将任何内容复制到 GPU。此外,正如您所指出的,即使在您提交命令缓冲区之后,您也不能假设数据已完全复制到 GPU。

其次,在简单的情况下,您应该将帧的所有绘图放入单个命令缓冲区。创建和提交大量命令缓冲区(例如每个绘制对象一个)会增加开销。

这两点结合起来意味着您通常不能在同一帧内重复使用资源。基本上,您将不得不双倍或三倍缓冲才能同时获得正确性和良好性能。

一种典型的技术是创建一个由信号量保护的小型缓冲区池。信号量计数最初是池中缓冲区的数量。需要缓冲区的代码在信号量上等待,当成功时,从池中取出缓冲区。它还应该向命令缓冲区添加一个完成处理程序,将缓冲区放回池中并向信号量发出信号。

可以使用动态缓冲池。如果代码想要一个缓冲区并且池是空的,它会创建一个缓冲区而不是阻塞。然后,完成后,它将缓冲区添加到池中,从而有效地增加了池的大小。但是,这样做通常没有意义。如果 CPU 运行在 GPU 之前,你只需要三个以上的缓冲区,这并没有真正的好处。

至于您希望每个对象自己绘制,这当然可以做到。我会使用一个大的顶点缓冲区以及一些关于到目前为止已经使用了多少的元数据。每个需要绘制的对象都会将其顶点数据附加到缓冲区并对其引用该顶点数据的绘制命令进行编码。您可以使用vertexStart 参数让绘图命令引用顶点缓冲区中的正确位置。

您还应该考虑使用图元重新启动值进行索引绘制,这样只有一个绘制命令可以绘制所有图元。每个对象都会将其图元添加到共享的顶点数据和索引缓冲区中,然后一些高级控制器会进行绘制。

【讨论】:

我发现了 Apple 的 CPU-GPU 同步示例,它体现了您提出的设计,这很棒。然而,令人沮丧的是,它有点不完整,因为它不会自行清理——没有 dispatch_release() 调用,实际上也没有 AAPLRenderer 的 dealloc 方法。棘手的是窗口可能会关闭,视图和渲染器会被释放,命令缓冲区仍在运行,对吧?然后完成处理程序将尝试向信号量发出信号,该信号量将不再存在。或者实际上,dispatch_release() 会出错,因为信号量是 经过反思,我想我应该 (a) 创建初始值为 0 的 inFlightSemaphore,(b) 立即发出 3 次信号以指示三个缓冲区可用,然后在 dealloc 时,永久超时等待信号量三次,以便此时的代码“拥有”所有三个顶点缓冲区。然后我可以处理缓冲区,dispatch_release() 信号量,并且知道所有内容都已安全清理。听起来对吗? 这会起作用,尽管它可能会阻塞主线程(如果这是调用清理的地方)一段时间。默认情况下,当涉及它们的命令被编码时,缓冲区由命令缓冲区保留,直到它完成(或者可能被释放)。因为完成处理程序引用了信号量、缓冲区和池,所以这些都保留到处理程序本身被释放(在它被调用之后)。换句话说,我认为清理工作比您想象的要简单。 也许我不太了解信号量。但是我已经将信号量声明为我的渲染类中的一个成员(如在 Apple 示例中)。所以如果我在所有命令缓冲区完成之前让dealloc通过,渲染类实例将被释放,然后命令缓冲区将完成,并尝试修改不再存在的信号量,以便访问以未定义的效果释放内存。没有? 是的,Grand Central Dispatch 以 C API 开始,不受 ARC 管理。但是,从 macOS 10.8 和 ios 6.0 开始,调度对象是 Objective-C 对象,由 ARC 管理。请参阅documentation for dispatch_release() 中的说明。此外,块捕获引用的变量并进行必要的内存管理。因此,该块保留信号量(或self,取决于它的访问方式),并且命令缓冲区在将其添加为完成处理程序后保留该块。

以上是关于何时可以安全地重写和重用 MTLBuffer 或其他 Metal 顶点缓冲区?的主要内容,如果未能解决你的问题,请参考以下文章

将未对齐的缓冲区提供给 MTLBuffer 是不是安全?

重用、重写还是重构?

我应该在何时何地将值存储在 BLoC 本身或其状态中

可以安全地重用 MD5CryptoServiceProvider 来计算多线程代码中的 md5 哈希吗?

如何在 C++ 中安全地实现可重用的暂存存储器?

Metalkit:MTLBuffer和swift 3中的指针