何时可以安全地重复使用MTLBuffer或其他Metal顶点缓冲区进行写入?

7
我刚开始学习Metal,在掌握一些基本概念上遇到了问题。我已经阅读了很多关于Metal的网页,并按照苹果的例子进行实践,但我的理解仍然存在漏洞。 我认为我最困惑的点是:处理顶点缓冲区的正确方法是什么,以及何时可以安全地重复使用它们?这种困惑有几个方面,我将在下面描述,也许这些不同方面的困惑需要用不同的方式解决。
具体来说,我正在macOS上使用Objective-C中的MTKView子类显示非常简单的2D形状:视图的整体框架带有内部背景颜色,其内部包含0个以上的矩形子框架,每个子框架内部有不同的背景颜色,然后每个子框架内还有各种颜色的扁平着色正方形。 我的顶点函数只是一个简单的坐标转换,我的片段函数只是根据Apple的三角形演示应用程序传递接收到的颜色。 对于仅具有单个正方形的单个子框架,我已将其正常工作。 到目前为止都很好。
有几件事情让我感到困惑。
一:我可以设计我的代码使用单个顶点缓冲区和单个drawPrimitives:调用来呈现整个内容,以一次大规模的操作绘制所有(子)框架和正方形。然而,这并不是最优的,因为它会破坏我的封装代码,其中每个子框架代表一个对象的状态(包含0个或多个正方形的内容)。我希望允许每个对象负责绘制其自己的内容。因此,最好让每个对象设置一个顶点缓冲区,并进行自己的drawPrimitives:调用。但是,由于对象将按顺序绘制(这是单线程应用程序),因此我希望跨所有这些绘图操作重复使用相同的顶点缓冲区,而不是让每个对象分配和拥有单独的顶点缓冲区。但是我能做到吗?在调用drawPrimitives:之后,我想缓冲区的内容必须被复制到GPU上,并且我认为(?)这不是同步完成的,因此不能立即开始修改顶点缓冲区以进行下一个对象的绘制。那么:如何知道Metal何时完成缓冲区并且可以再次开始修改?
二:即使#1有明确定义的答案,可以阻塞直到Metal完成缓冲区,然后开始修改以进行下一个drawPrimitives:调用,这是否是合理的设计?我想这意味着我的CPU线程会不断阻塞等待内存传输,这并不好。 那么,这是否基本上将我推向每个对象都有自己的顶点缓冲区的设计?
三:好的,假设每个对象都有它自己的顶点缓冲区,或者我使用一个大的顶点缓冲区进行一次“大爆发”渲染整个物体(我认为这个问题适用于这两种设计)。在我调用presentDrawable:并在我的命令缓冲区上调用commit之后,我的应用程序将会做一些工作,然后会尝试更新显示,因此我的绘图代码现在再次执行。我想重复使用之前分配的顶点缓冲区,在其中覆盖数据以进行新的、更新的显示。但是再次强调:我怎么知道这是安全的呢?据我所知,commit返回到我的代码中并不意味着Metal已经完成将我的顶点缓冲区复制到GPU的工作,而且在一般情况下,我必须假定它可能需要任意长的时间,因此当我重新输入我的绘图代码时,它可能还没有完成。正确的方法是什么?还有:我只是阻塞等待它们可用(无论我应该如何做),还是应该有第二组顶点缓冲区,以防Metal仍然忙于第一组?(这似乎只是把问题推到更远的未来,因为当第三次更新时,我的绘图代码被输入时,前面使用的两组缓冲区可能还没有可用,对吧?那么我可以添加第三组顶点缓冲区,但是第四次更新...)
四:为了绘制帧和子帧,我想编写一个可重复使用的“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个回答

5
你基本问题的简短回答是:在命令缓冲区完成之前,不应覆盖被添加到命令缓冲区中的资源。最好的方法是添加一个完成处理程序来确定这一点。你还可以轮询命令缓存的 status 属性,但那不太好。
首先,在提交命令缓冲区之前,没有任何东西被复制到GPU上。此外,正如你所指出的,即使在提交了命令缓冲区之后,也不能假设数据已经完全复制到GPU。
其次,在简单情况下,你应该将所有帧的绘图都放入一个命令缓冲区中。创建和提交大量的命令缓存(例如为每个绘图对象创建一个)会增加开销。
这两点的结合意味着你通常不能在同一帧中重复使用资源。基本上,你需要双缓冲或三缓冲才能同时实现正确性和良好的性能。
典型的技术是创建一个由信号量保护的小型缓冲池。信号量计数最初为池中缓冲区的数量。需要缓冲区的代码等待信号量,成功时从池中取出一个缓冲区。它还应向命令缓存添加完成处理程序,将缓冲区放回池中并信号量发出信号。
你可以使用动态缓冲池。如果代码需要缓冲区并且池为空,则创建一个缓冲区而不是阻塞。然后,当完成时,将缓冲区添加到池中,有效地增加了池的大小。但通常没有必要这样做。只有当CPU远远领先于GPU时,你才需要更多的缓冲区,而这并没有实际好处。
至于你希望每个对象都绘制自己,这是可以做到的。我会使用大型顶点缓冲区以及一些关于其已使用了多少的元数据。需要绘制的每个对象将其顶点数据附加到缓冲区,并对引用该顶点数据的绘图命令进行编码。你将使用 vertexStart 参数来让绘图命令引用顶点缓冲区中正确的位置。
你还应考虑具有原始重启值的索引绘制,从而只有一个绘制命令绘制所有原语。每个对象都会将其原语添加到共享的顶点数据和索引缓冲区中,然后一些高级控制器会进行绘制。

我发现了苹果的CPU-GPU同步示例,它展示了你提出的设计,这很棒。然而,有点令人沮丧的是,它有点不完整,因为它没有清理自己 - 没有dispatch_release()调用,也没有AAPLRenderer的dealloc方法。麻烦的是,窗口可能会关闭,视图和渲染器被dealloced,而命令缓冲区仍在运行,对吧?然后完成处理程序将尝试信号量,但信号量已不存在。或者实际上,dispatch_release()会出错,因为信号量会小于3。 - bhaller
经过思考,我认为我应该(a)创建一个初始值为0的inFlightSemaphore,(b)立即发出三次信号以表示有三个缓冲区可用,然后在dealloc时间等待信号三次,直到超时,以便在那一点上的代码“拥有”所有三个顶点缓冲区。然后我可以处理缓冲区,dispatch_release()信号量,并知道一切都已经安全清理了。这听起来对吗? - bhaller
那样做是可行的,尽管它可能会在一段时间内阻塞主线程(如果清理是在那里调用的话)。默认情况下,当涉及到缓冲区的命令被编码时,缓冲区会被命令缓冲区保留直到完成(或者可能被释放)。由于完成处理程序引用了信号量、缓冲区和池,这些都会被保留,直到处理程序本身被释放(在调用后)。换句话说,我认为清理比你想象的要简单。 - Ken Thomases
也许我并不真正理解信号量。但是我已经像苹果的示例一样在我的渲染类中将信号量声明为成员了。所以,如果我在所有命令缓冲区完成之前让dealloc执行,渲染类实例将被释放,然后稍后命令缓冲区将完成,并尝试修改信号量,但它已经不存在了,因此将访问已释放的内存,并产生未定义的影响。对吗? - bhaller
我想,对我来说问题在于我不知道dispatch_semaphore_t是什么。如果它是一个具有保留计数的Obj-C对象的指针或类似物,则我理解你的观点,尽管我不完全清楚命令缓冲区何时/如何将其保留。但是,如果我遵循Xcode中的定义,我会得到DISPATCH_DECL(dispatch_semaphore);,然后转到#define DISPATCH_DECL(name) OS_OBJECT_DECL_SUBCLASS(name, dispatch_object),以此类推。看起来它最终确实会转换为NSObject; 但这是否有文档记录和保证呢? - bhaller
1
是的,Grand Central Dispatch最初作为C API,并不受ARC管理。然而,自从macOS 10.8和iOS 6.0以来,dispatch对象已成为Objective-C对象并由ARC管理。请参见 dispatch_release() 的文档中的注释。此外,块捕获引用变量并执行必要的内存管理。因此,该块会保留信号量(或self,具体取决于访问方式),而命令缓冲区在将其添加为完成处理程序后会保留该块。 - Ken Thomases

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