OpenGL对象创建

4

我现在正在建模一些OpenGL库,以便玩弄图形编程等。因此,我使用类来包装特定的OpenGL函数调用,例如纹理创建、着色器创建等等。到目前为止一切都很好。

我的问题:

所有的OpenGL调用必须由拥有创建的OpenGL上下文的线程完成(至少在Windows下,其他线程将不起作用并创建一个OpenGL错误)。因此,为了获得一个OpenGL上下文,我首先创建一个窗口类的实例(只是另一个对Win API调用的包装器),最后为该窗口创建一个OpenGL上下文。这听起来对我来说非常合乎逻辑。(如果我的设计中已经有缺陷让你尖叫,请告诉我...)

如果我想创建一个纹理或任何需要OpenGL调用创建的对象,我基本上会这样做(一个OpenGL对象的构造函数示例):

opengl_object()
{
    //do necessary stuff for object initialisation
    //pass object to the OpenGL thread for final contruction
    //wait until object is constructed by the OpenGL thread 
}

所以,简单来说,我使用以下方式创建一个像任何其他对象一样的对象:
 opengl_object obj;

在构造函数中,它将自己放入OpenGL对象队列中,以便由OpenGL上下文线程创建。然后,OpenGL上下文线程调用一个虚拟函数,该函数在所有OpenGL对象中都有实现,并包含必要的OpenGL调用以最终创建对象。

我曾经认为,这种处理问题的方式很好。然而,现在我觉得我非常错了。

问题是,尽管上述方法到目前为止完全正常,但是当类层次结构变得更深时,我遇到了麻烦。例如(这并不完美,但它展示了我的问题):

假设我有一个名为sprite的类,表示一个精灵。它有自己的OpenGL线程创建函数,在其中顶点和纹理坐标被加载到显卡内存中等等。到目前为止没有问题。 再假设,我想要有两种渲染精灵的方式。一种是实例化的,另一种是通过其他方式。因此,我最终会得到两个类,即sprite_instanced和sprite_not_instanced。它们都派生自sprite类,因为它们都是只以不同的方式呈现的精灵。但是,sprite_instanced和sprite_not_instanced需要在其创建函数中进一步进行OpenGL调用。

到目前为止,我的解决方案(我感到非常糟糕!)

我对C++中的对象生成方式以及它对虚拟函数的影响有一些了解。因此,我决定仅使用sprite类的虚拟创建函数将顶点数据等加载到图形内存中。然后,sprite_instanced的虚拟创建方法将准备好以实例化的方式呈现该精灵。 因此,如果我想要编写

sprite_instanced s;

首先,精灵构造函数被调用并进行了一些初始化,构造线程将对象传递给OpenGL线程。此时,传递的对象仅是普通精灵,因此将调用 sprite::create 并且 OpenGL 线程将创建普通精灵。之后,构造线程将再次调用 sprite_instanced 的构造函数,再做一些初始化并将对象传递给OpenGL线程。但这一次,它是 sprite_instanced,因此将调用 sprite_instanced::create。
所以,如果以上假设是正确的,那么在我的情况下,一切都按照预期发生。我花费了最后一个小时阅读有关从构造函数调用虚函数以及如何构建虚表等方面的内容。我运行了一些测试来检查我的假设,但那可能是特定于编译器的,所以我不会100%依赖它们。此外,这种方法感觉很糟糕,像一个可怕的黑客。
另一个解决方案
另一个可能性是在OpenGL线程类中实现工厂方法来处理这个问题。因此,我可以在这些对象的构造函数中完成所有OpenGL调用。但是,在这种情况下,我需要很多函数(或一种基于模板的方法),当OpenGL线程有更多要做的事情时,它感觉像潜在的渲染时间损失......
我的问题
按照我上面描述的方式处理它是否可以?还是我应该放弃那些东西并做些其他事情?

1
这对于一个所谓的“小型OpenGL库”来说是一个非常复杂的部分。它甚至是不必要的多线程。你所概述的数据组织方式并不特别高效。你希望“精灵”的概念比“纹理”和“网格”等概念更高级。高得多. - Nicol Bolas
是的,它以某种方式变得相当大了...我想我只是在太多不同的方式上都很好奇。然而,我不明白我所概述的数据组织方式在哪方面不会特别高效。你指的是什么方面?性能?可维护性?还是其他任何方面?也许这只是我在这里简化的一部分,但我希望能够澄清这一点... - Shelling
我发现(VS编译器)如果构造函数调用虚函数,总是调用基本实现。但是,如果我从构造函数调用的虚函数中调用虚函数,这将正常工作。 - Luca
多线程实际渲染过程本身不会带来任何好处。因为只有一个GPU,所以驱动程序最终仍然必须自己序列化调用。D3D 11提供了一种批处理调用的方法(命令列表)。我不确定GL 4是否也有这样的功能。当我读到它时,我瞪大了眼睛,认为除非在某些非常特殊的情况下,否则它不会对性能产生太大影响。无论如何,我认为你在这里的解决方案可能有点过度设计了。 - Robinson
6个回答

5
你已经得到了一些很好的建议。这里我来加点料:
理解OpenGL的一个重要方面是它是一个状态机,不需要复杂的“初始化”。你只需使用它就可以了。缓冲对象(纹理、顶点缓冲区对象、像素缓冲区对象)可能使其看起来有所不同,大多数教程和实际应用程序确实在应用程序启动时填充缓冲区对象。
但是,在常规程序执行期间创建它们也完全可以。在我的3D引擎中,我利用双缓冲交换期间的空闲CPU时间,进行缓冲区对象的异步上传for(b in buffers){glMapBuffer(b.target, GL_WRITE_ONLY);}start_buffer_filling_thread();SwapBuffers();wait_for_buffer_filling_thread();for(b in buffers){glUnmapBuffer(b.target);}
还要重要的是,对于简单的东西,比如精灵,不应该为每个精灵单独给定自己的VBO。通常把大量精灵分组放入一个VBO中。你不必全部绘制它们,因为你可以偏移进入VBO并进行部分绘图调用。但是这种OpenGL常见模式(几何对象共享缓冲区对象)完全违反了类原则。因此你需要一些缓冲区对象管理器,它会为消费者分配地址空间的片段。
使用类层次结构与OpenGL本身并不是个坏主意,但这种层次结构应该比OpenGL高几个级别。如果你只是将OpenGL直接映射到类上,那么除了复杂性和膨胀之外什么也得不到。如果我直接或通过类调用OpenGL函数,我仍然必须做所有的苦差事。因此,纹理类不应仅仅映射纹理对象的概念,而且还应该负责与像素缓冲区对象(如果使用)进行交互。
如果你真的想在类中包装OpenGL,我强烈建议不要使用虚拟函数,而是使用静态(即在编译单元级别上)内联类,这样它们就成为语法糖,编译器不会膨胀太多。

谢谢你的回答。我会重新考虑我的设计,已经有了一些改进,我很满意。有一件事似乎导致了混淆:我只是选择精灵作为一个例子。这是我想到的第一件事,显然不是一个好主意。下次我会想一个更好的例子。 - Shelling

3
这个问题的简化在于假设单个上下文在单个线程上是当前的;实际上,可能存在多个OpenGL上下文,也可以在不同的线程上(而且我们考虑上下文名称空间共享)。
首先,我认为你应该将OpenGL调用与对象构造函数分开。这样可以让您设置一个对象,而不必担心OpenGL上下文的当前性;接着,可以将对象排队以在主渲染线程中创建。
例如,假设我们有2个队列:一个保存Texture对象,用于从文件系统加载纹理数据,另一个保存Texture对象,用于在加载数据后将纹理数据上传到GPU内存中。
线程1:纹理加载器
{
    for (;;) {
        while (textureLoadQueue.Size() > 0) {
            Texture obj = textureLoadQueue.Dequeue();

            obj.Load();
            textureUploadQueue.Enqueue(obj);
        }
    }
}

线程2:纹理上传器代码部分,基本上是主渲染线程

{
    while (textureUploadQueue.Size() > 0) {
        Texture obj = textureUploadQueue.Dequeue();

        obj.Upload(ctx);
    }
}

纹理对象的构造函数应该如下所示:

Texture对象的构造函数应该如下所示:

Texture::Texture(const char *path)
{
    mImagePath = path;
    textureLoadQueue.Enqueue(this);
}

这只是一个示例。当然,每个对象都有不同的要求,但这种解决方案是最具可扩展性的。


我的解决方案主要由接口IRenderObject描述(文档与当前实现远不相同,因为我正在进行大量重构,开发还处于非常初级的阶段)。这个解决方案适用于C#语言,由于垃圾回收管理,其引入了额外的复杂性,但概念可以完美适应C++语言。

基本上,接口IRenderObject定义了一个基本的OpenGL对象:

  • 它有一个名称(由“Gen”例程返回)
  • 它可以使用当前的OpenGL上下文创建
  • 它可以使用当前的OpenGL上下文删除
  • 它可以使用“OpenGL垃圾回收器”异步释放

创建/删除操作非常直观。使用一个抽象的RenderContext对象执行这些操作,可以执行有用的检查,以查找对象创建/删除中的错误:

  • 创建方法检查上下文是否当前,上下文是否能够创建该类型的对象等等...
  • 删除方法检查上下文是否当前,并更重要的是检查传递的上下文参数是否与创建底层IRenderObject的上下文共享相同的对象名称空间。

以下是删除方法的示例。虽然代码可以工作,但不符合预期:

RenderContext ctx1 = new RenderContext(), ctx2 = new RenderContext();
Texture tex1, tex2;

ctx1.MakeCurrent(true);
tex1 = new Texture2D();
tex1.Load("example.bmp");
tex1.Create(ctx1);            // In this case, we have texture object name = 1

ctx2.MakeCurrent(true);
tex2 = new Texture2D();
tex2.Load("example.bmp");
tex2.Create(ctx2);            // In this case, we have texture object name = 1, the same has before since the two contexts are not sharing the object name space

// Somewhere in the code
ctx1.MakeCurrent(true);

tex2.Delete(ctx1);            // Works, but it actually delete the texture represented by tex1!!!

异步释放操作旨在删除对象,但没有当前上下文(事实上该方法不带任何RenderContext参数)。这可能会导致对象在独立线程中被处理,而该线程没有当前上下文;但我也不能依赖垃圾收集器(C++没有垃圾收集器),因为它在一个我无法控制的线程中执行。此外,最好实现IDisposable接口,以便应用程序代码可以控制OpenGL对象的生命周期。

OpenGL垃圾收集器在具有正确上下文的线程上执行。


这个答案明显太长了。 - Luca

2
  1. 在构造函数中调用任何虚函数都是不好的做法。虚函数的调用将无法正常完成。

  2. 你的数据结构非常混乱。你应该研究工厂对象的概念。这些对象用于构建其他对象。你应该有一个SpriteFactory,它被推入某种队列或类似的东西中。那个SpriteFactory应该是创建Sprite对象本身的东西。这样,你就不会有一个部分构造的对象的概念,其中创建它会将其推入队列等等。

    确实,每当你开始编写“Objectname::Create”时,请停下来思考,“我真的应该使用工厂对象。”


我考虑使用工厂来创建对象。然而,在这种情况下,我没有找到一个好的方法来进行所需的OpenGL调用,或者如何将创建的对象传递给调用工厂的线程。我还没有想出解决这两个问题的同时的想法。但我仍在思考中。(我也不太喜欢构造函数中的虚拟调用,但它确实起作用。但正如我所说,它感觉有些糟糕) - Shelling

1
我建议在对象构造时避免将其插入到GL线程队列中。这应该是一个显式的步骤,例如:
gfxObj_t thing(arg) // read a file or something in constructor
mWindow.addGfxObj(thing) // put the thing in mWindow's queue

这使您可以执行诸如构建一组对象,然后一次将它们全部放入队列中,并保证在调用任何虚拟函数之前结束构造函数。请注意,将enqueue放在构造函数的末尾不会保证这一点,因为构造函数总是从最高级别的类向下调用。这意味着,如果您将一个对象排队以调用其虚拟函数,则派生类将在其自己的构造函数开始操作之前被排队。这意味着您有一个竞争条件,可能会导致对未初始化对象的操作!如果您没有意识到自己所做的事情,那么调试将是一场噩梦。


我开始时确实这样做了。但是,在调用构造函数后,它留下了一个部分构造的对象(缺少OpenGL部分)。在当前的解决方案中,我通过等待基类完全构造(包括OpenGL部分),然后开始构建派生类来处理竞态条件。尽管到目前为止我运行的每个测试都按预期工作,但我正在寻找像Nicol Bolas提到的解决方案。 - Shelling

1

OpenGL是为C语言设计的,而不是C++。我所学到的最好的方法是编写函数而不是类来包装OpenGL函数,因为OpenGL在内部管理其自己的对象。使用类来加载数据,然后将其传递给处理OpenGL的C风格函数。在构造函数/析构函数中生成/释放OpenGL缓冲区时应非常小心!


2
为什么不呢?我认为RAII应该可以应用于OpenGL对象。只要确保在上下文创建之前没有创建任何这些对象,并且在上下文消失之前销毁所有对象,那么问题在哪里呢? - Nicol Bolas
实际上,使用RAII是这个想法的背后。当然,在创建时我会确保有一个OpenGL上下文可用,并且在销毁时不再以任何方式使用对象。此外,上下文会在销毁时删除对象的OpenGL数据(这不是问题,因为只有一个上下文)。 - Shelling
1
@Pubby8,正是这个部分表明违反RAII并不是最佳的行动方针。 - pmr
@Luca 没有什么能阻止你使用引用计数、不可复制或可移动的类。实际上,这就是我在将GL资源封装在类中时看到的主要优势之一:你可以很好地控制这种行为。 - pmr
@LucaPiccioni:RAII 当然包括删除、堆等。智能指针是 RAII 最早覆盖的内容之一,也是引入该术语时的激励示例!为了处理这样的事情,不需要引用计数。您可以使用状态模式,并将具有给定状态寿命(如纹理)的对象放置在相应的状态对象中。当状态机转换时,它会自动清理,这可能会在某些异步事件之后发生。这是基本的面向对象编程与 RAII。 - ex0du5
显示剩余3条评论

1

我认为这里的问题不在于RAII,也不在于OpenGL是C风格接口。问题在于你假设sprite和sprite_instanced都应该派生自一个共同的基类。这些问题在类层次结构中经常发生,而我通过许多错误学到的第一课关于面向对象编程的知识是,封装通常比继承更好。除非你要通过抽象接口进行派生。

换句话说,不要被这两个类都有“sprite”名称所迷惑。它们在行为上完全不同。对于它们共享的任何公共功能,实现一个封装该功能的抽象基类。


谢谢你的回答。很抱歉因为选择那个精灵示例而在这个问题上造成了一些混淆。我没有以那种方式实现它,也不会这样做。我只是需要一个例子来表达我的想法,那是我脑海中浮现的第一个(显然不好的)例子。 - Shelling
即便如此,我认为这个原则仍然适用。你需要一个工厂来创建东西(正如其他人所指出的),最好返回抽象接口指针(不是必须的,但我倾向于几乎所有情况下都使用shared_ptr)。工厂本身可以是抽象的,然后你可以创建一个OpenGL工厂或D3D工厂,每个工厂实现都可以为OpenGL或D3D创建对象。 - Robinson

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