• [译]OGRE3D 渲染系统线程化


    译者序


    偶然在网上看到这篇文章,自己很想仔细研究一下。但搜寻半天不见中文版。于是自己斗胆翻译了一下。文中不免有漏洞百出,甚至可以说有些地方不及Google翻译得好。但这样总的来说是出了一个中文版,而我自己在翻译过程中也会停下来仔细思考。

    OGRE这个线程化的文章很老了。因为OGRE目前已经支持多线程渲染。 这篇文章貌似是某些人研究出来的三个线程化方案,并给出了测试结果。以向OGRE社区证明线程化方案的可行性。 对于许多想研究渲染线程化的人来说,是一篇值得参考的文章。文中提出了许多在不同情况下线程化时遇到的问题,以及需要注意的问题。值得一读/

    PS:先前研究渲染系统线程化的时候翻到了这篇文章,于是一边看一边写出了汉语。 文中写多地方翻译得很不通顺,见谅。

    介绍


    OGRE3D(a.k.a. Ogre)是目前最常用的开放源代码 3D 引擎之一。它是一款功能完善的通用 3D 引擎,可应用于从游戏到科学模拟等多种商用产品。该引擎由来自开放源代码社区的数百名技术人员历经五年时间而开发成功。如欲了解 Ogre 的详细信息,请访问网站 www.ogre3d.org 。

    然而,尽管 Ogre 功能强大,但是它却在技术上存在一个重要缺憾,那就是它无法在系统中充分利用多个处理器的优势。目前,英特尔已经有多款双核产品上市,而超线程(HT)技术更是在多年前就已应用在英特尔® 奔腾® 4 处理器中。将 Ogre 线程化所实现的性能增益将丝毫不逊色于添加第二枚处理器所实现的性能增益。

    本文将向您介绍将 Ogre 渲染系统线程化的三种不同处理方法,并且我们将根据下文所描述的线程化目的,选择一种方法进行完整实施。

    线程化目标

    当我们在对OGRE线程化时,需要实现以下几个目标:

    ·通过改动最少的OGRE源码以使OGRE社区接受。

    ·在双核处理器上,相对于非线程化渲染的OGRE系统来说,提升25%的FPS,以平衡应用程序的CPU和GPU使用率

    ·在用户不知道的情况下对SDK包中的DEMO进行必要的变动,而不让用户在画面上有任何察觉。

    假设

    本文提及了OGRE中的许多类,以及使用了OGRE中的许多程序片段。所以,这里假设读者都是对OGRE的源码非常了解或者已经对源码进行过快速的查阅,否则将很难理解这些OGRE的特性。同样,读者也应该对线程的概念有所了解。这里就不再介绍更多关于线程的内容。

    限制

    这里所讨论的所有实现,都没有在超过两个处理器的机器上实验过。因此,这些技术对于双核处理器或双核心系统最适合不过。以后的文章中将会讨论如何在OGRE中创建一个线程队列,使OGRE以及使用OGRE的应用程序都能够在双核处理器以及多核上得到性能的提升。

    线程化OGRE渲染系统使用到的技术

    OGRE中有很多地方都可以被线程化,但线程化最能在多核上提升性能的是渲染系统。渲染系统在OGRE占据了巨大的一块,并且从某种意义上讲,它能单独地被外部程序访问。下面介绍三种将OGRE渲染系统线程化的例子。

    1、 在OGRE中对于渲染的调用可以被放在他自己的线程中。

    2、 一个线程化层可以被放在OgreMain和渲染系统插件之间。

    3、 渲染系统插件可以单开一个线程来调用图形API。

    上面这三种方案都有各自的优缺点,本文将会一一讨论。

    线程化并不会对每个应用程序有利

    注意,线程化只对那种花费在图形调用API和逻辑处理上的时间很接近的应用程序有好处。若其中一个较另一个差别很大,则看不到明显的效果。

    技术方案1:线程化OgreMain(高级线程化手段)


    在OgreMain中进行线程化是一种最高级的线程化手段,也表示能获得最高级的潜在性能。这是因为Ogre在做一次渲染的时候,需要做很多事情,并且不仅仅是提交某些命令或数据到显卡。比如,决定哪个摄相机是活动摄相机,遍历场景中所有可见物体,标志所有可见物体去渲染,等等。 下面的插图展示了一个Ogre渲染过程(为了简化起见,一些东西被忽略)。高级线程化将导致这里所有的过程被放置在他自己的线程里。

    clip_image002

    线程化问题

    采用这种方案有一个主要的问题就是,两个线程中将会发生代码重叠。打个比方,主线程和渲染线程都需要访问场景中某个场景对象的相同数据。主线程要更新它的位置和方向,然而此时渲染线程要读这些信息或者在渲染线程渲染前主线程修改了这些内容。从而导致渲染帧的内容与实际不符,会相差一帧。特别地,当渲染在渲染一个对象时主线程却要把它删除,就会出问题。如下图所示:

    clip_image004

    除了刚刚提到的线程自身的问题外,同样也存在处理器共享失败的问题。当一个变量被一个线程更新的时候,这个变量是处于这个线程所在的CPU缓存行中的,而另外一个线程也会访问这个缓存行中的内容的相同数据。由于它们共享缓存行,一个处理器需要清空整个缓存行,不管其它处理器是否做了修改。这就是这个高级线程方方案的问题所在。因为主线程和渲染线程使用同样的类的实例。 由于类体变量被相继地放置在内存中,因此他们要共享同样的缓存行。关于更多缓存行共享失败的问题。可以查看相关文章。

    避免问题

    为了避免上面提到的线程问题,这里提供了两个可以安全访问和更新对象的解决方案。

    1、 使用一个更新队列。

    2、 复制对象

    更新队列的方案通过维持一个对象的更新队列来防止访问重叠。见下图。 更新将只发生一次,即当主线程准备让渲染线程开始渲染的时候。当然,你需要等待渲染线程完成后才能进行第二次启用。这个方案有一个缺点,就是单处理系统上的CPU反而会承受这个更新队列的额外负担,而享受不到这个更新队列的好处。另一个缺点就是,当对硬件资源(如索引,顶点缓冲等)改变时。排队改变这些资源将会很困难,因为这些数据都非常大。

    clip_image006

    复制对象的方案从本质上讲,就是为一个经常变动的对象复制副本。在这种方案下,使用OGRE的应用程序将被要求复制一个经常需要更新的对象,因为只有它知道哪些对象是需要经常更新的。应用程序也不得不按照一定的方式来写:对象的处理是在对象被显示后的下一帧。(这点没有太明白,貌似意思是说,对象的处理和渲染为两个帧,一个帧拿来渲染,一个帧拿来处理,看到下面那图应该是这个意思)。当然,如果你的应用程序并不每帧更新对象,这也是一个问题,在这种情况下,你的对象有可能是在几帧后才被访问,这样就会导致冲突。也可以对其做一些优化,如只有对象中主线程和渲染线程要共享的数据才被复制,以此来减少负担。在这样的情况下对象将不再是一个复制品,但是将会有一个双缓冲用于存放你复制的这些东西。

    在下图中,注意那个object X 将保持一致性(假设更新速度大于30FPS)。但是object Y将不会,因为在前一帧进行了缩放,但是这个数据并没有被体现在复制对象中。

    clip_image008

    图里有许多关于同步的东西被我删掉了,但是上面着实能够反应这个技术的实现形式。

    上面说到的两个技术中,并没有任何一个技术被OGRE社区接受,它们都需要大量修改OGRE代码,因此并未继续。一个需要复制对象数据来控制数据修改的例子便是Frustum::updataView函数和_update函数,对于实现这个函数的所有类,都需要在渲染中被调用,以及OGRE的其它地方(渲染以外的地方)。

    在哪里进行线程化

    在OgreMain中进行线程化的一个理想的地方便是在Root::renderOneFrame中。这个函数调用了主渲染_Root::_updateAllRenderTargets,这个函数可以轻易地被封装一次。

    下面是一些实现上面想法的示例代码。

    …
    
    _beginthreadex(0,0,Root::renderThreadFuc,0,0,0);
    
    …
    
    /*static*/ unsigned int Root::renderThreadFunc( )
    {
        while(1)
        {
            waitForStartRendering( );
            _setStartRendering(flase);
            _updateAllRenderTargets( );
            _setRenderingComplete(true);
        }
    }
    
    bool Root::renderOneFrame( )
    {
        if(!_fireFrameStarted( ) )
            return false;
        if(mThreadedRendering)
        {
            _waitForRenderingComplete( );
            _setRenderingComplete(false);
            _setStartRendering(true);
        }
        else
        {
            _updateAllRenderTargets( );
        }
    }
            

    _wait 和_set函数演示了操作系统依赖的同步函数调用,例如,WINDOWS版本的_waitForRenderingComplete将会包含一个WaitForSingleObject调用。注意当多线程开启的时候,应该在真正渲染完成之前调用_fireFrameEnded函数。

    技术方案2:创建一个线程渲染系统层(中级线程化手段)


    创建一个线程化的渲染系统层对OGRE渲染系统来说是一个很不显眼的方案。但是由于OGRE渲染系统的复杂性,它也是最困难的方案。渲染层从本质上讲是对渲染系统插件(如D3D,OPENGL)的一个封装。想要在OGRE中创建和集成一个这样的附加渲染系统,只需要对OGRE进行较少的改动。但是创建创建一个线程化的渲染系统层,又是另一回事了。

    clip_image010

    为了创建一个线程化的渲染系统层,你至少需要在OgreMain中实现Ogre::RenderSystem 类和Ogre:RenderWindow类。这两个类仅仅是界于Ogre和实际的渲染器插件之间的一个层。这个层的工作并不是简单的将对插件的函数调用进行封装。需要决定要做哪些什么,以便调用渲染器插件,因为这个方案的目标是将渲染的工作分离到另一个线程中。在实面这些类的函数时,有几事情需要思考。

    ·习惯性地(如,所有函数调用仅仅是在开始渲染之前调用。)可以仅是对渲染器插件的直接调用,(相当于函数转发)。也可以在包装的同时进行一些必要的初始化。

    ·在调用渲染器插件进行创建操作时,将需要等待前一帧渲染完成。并且需要一个包装类来包装那个渲染器返回(提供一个已经存在的实例)的与创建相当的类。一个需要包装类的好例子便是渲染器插件返回的RenderWindow类。这个类的实例通过RenderWindow:createRenderWindow创建并返回。

    ·某些函数需要访问基类。RenderSystem和RenderWindow类需要调用一些基类方法来完成一些内部事情。在OGRE中不这样做,会导致不正确的行为。

    ·渲染用的函数需要被排队,以便享受到多线程的好处。渲染线程将会遍历那个队列,并按顺序调用那些函数。 RenderWindow中的swapBuffers函数是一个例外。它的包装函数既要加入渲染队列管理,但它又是向渲染线程发出信号,执行渲染队列中的函数的地方。

    上面提到的几点描述了实现这个方案需要做的事情,也还有其它一些小问题需要考虑,并且一些问题需要在实现的时候处理。

    线程化的问题

    这个实现和“方案1:高级线程化方案”一样,存在同样的问题。除开这种情况,最好的解决方案就是复制需要共享的数据。因为这个中间层不拥有Ogre中的类。也有其它一些从“低级线程化方案”中借鉴而来的解决办法来完成这个实现,从而对显卡资源提供一个线程安全访问手段。

    在哪里线程化

    正如先前提到的,这将是线程化Ogre渲染系统的一个最不显眼的方案。所需要对Ogre做的轻微改变仅是对Ogre现有的渲染系统增加一个线程化的渲染系统层。像这样

    void Root::AddRenderSystem(RenderSystem *newRender)
    {
        mRenderers.push_back(newRender);
    #ifdef __THEARDRENDERSYSTEM__
        if(mThreadRenderSystem)
        {
            RenderSystem* newTRender = new ThreadedRenderSystem(newRender);
            if(newTRender != NULL)
            {
                mRenderers.push_back( newTRender);
            }
        }
    #endif
    }

    技术方案3:线程化渲染插件(低级线程化手段)


    线程化一个特定的渲染插件带来了最低级的适应性,因为它和特定的技术(如D3D,OPENGL)绑定起来。但是它也使你能够最大限度地操纵硬件资源。

    实现这个方案最干净利落的办法就是创建一个介于API和渲染插件之间的层,用来处理线程化。(如下图)

    clip_image012

    在这种方法下,它仅仅是用你的层来替换API接口。并且每个一调用从渲染插件的调用都是线程安全的,因为你的这个层处理了所有的调用。为D3D做这个,仅仅是用你自己的包装类和方法替换了IDirect3D的接口。对于OPENGL,你将移除所有的OPENGL头文件,并用你自己包装好后的头文件替换掉它们。有可能你需要用一个命名空间来包装OPENGL函数,以免出现名词冲突。

    这个方案依然和“中级线程化手段”一样需要考虑些线程化相关的东西。

    ·初始化不需要多线程

    ·创建函数以及一些get函数需要在渲染完一帧前等待。

    ·渲染命令,和伴随参数将需要加入队列中。

    关于索引和顶点缓冲的加锁

    对于所有的方案,都存在一个使用硬件资源时的潜在线程问题。对索引顶点缓冲区的加锁就是一个很大的挑战,因为某些应用程序在执行的时候总是会反复对这些缓冲区进行加锁和解锁。比如对于动态缓冲区,总是会每帧都改变它的内容来实现画面的变化。一些应用程序也会重用缓冲区以使多个对象每帧都能够共享同样的顶点和索引缓冲区。为了解决这个问题,我们需要缓冲这些缓冲区。然而,可以通过很多方法来实现,下面有两种缓冲办法:

    1、 部分缓冲区加锁,这是一个类似于双缓冲的缓冲技术。我们将在显卡中创建两个缓冲区,而不是一个。在这种情况下,当应用程序在写一个缓冲区的时候,渲染线程将使用另一个缓冲区中的数据进行渲染。这个方案可以提升渲染速度但是有一个缺点,就是会消耗更多的显存。并且不能处理应用程序在每一帧对同一缓冲区连续写两次的情况。

    clip_image014

    2、 完全缓冲区加锁,这个技术保持一个对于缓冲区的所有锁的副本。保留一个本地缓冲区并在上面进行所有的修改。当解锁它的时候,那些数据就会被放入一个参数队列,这样就可以对每次加锁/解锁维护一个唯一的副本。虽然这样可以对缓冲区数据进行精确的维护,但当某些应用程序在锁较大的数据的时候效果会大打折扣。

    clip_image016

    为什么锁表面的时候不用缓冲

    表面(如前台缓冲,后台缓冲,纹理等)通常消耗大量的存储空间。 所以,在对表面加锁时缓冲是无用的,也是不必要的。为什么没有用呢,因为在上面的加锁缓冲技术中,一个显卡会瞬间消耗掉大量的存储空间,因为这个时候我们需要创建两个表面。 例如:一个1024X1024 32bpp的纹理通常会消耗掉4MB的显存,但是如果采用了上面的缓冲锁技术,那么将会占用8MB的显存。这个技术将会导致纹理可用的显存资源直接减半。而对于完全缓冲区锁技术。则会消耗了大量的系统内存并且花费较多时间来拷贝数据。因为这个技术在系统内存中维护了一个数据的副本。所以它将会一次性地为这个表面分配大量的空间。当解锁的时候,又会将数据拷贝到参数队列里,并且参数队列在必要的时候,也会分配较大的空间。在渲染时,渲染线程同样需要将数据从参数队列中取出,并拷贝到真正的表面上。所有的这些拷贝操作,都会导致加锁和解锁的速度大大降低。

    表面加锁时缓冲不必要是因为应用程序不需要直接对表面(纹理除外)进行读写,为了取得更好的性能,可以使用显卡直接对表面进行操作。纹理,从另一方面讲,仅会被加锁一次,然后从文件中加载信息到它的表面。所以,对于纹理的加锁,可以让主线程等待渲染线程返回,然后再进行加锁,而不用对其进行缓冲。注意,等待渲染线程返回并加锁有可能导致错误。因为有可能显卡正在填充这个表面资源。记住,频繁的对表面进行加锁将会导致因为主线程的强制等待而使效率降低。

    在哪里进行线程化

    这个方案的实现只能是在包装图形API的那个包装类中。每一个函数都会有不同的实现,因为它们有可能是直接调用图形API,而有可能是要在调用图形API前等待前一帧绘制完成。或者是将数据放入渲染线程的队列,让渲染线程去调用API。也可以对其进行一些优化,采用在包装类中做一些缓存来缓存那些需要等待的调用(译注:个人理解是因为每一帧需要等待的调用可能不止一个,将他们缓存起来。这样当渲染线程返回时,一并调用这些函数,就能少了许多等待)。你还需要的就是修改渲染系统插件的代码,用你的包装类函数替换掉所有的图形API调用。

    下面的代码演示了如何进行IUnknown接口的包装。

    D3D9WrapperUnknown:: D3D9WrapperUnknown
    D3D9WrapperDevice* pD3DDevice)
    :m_pD3DDevice(pD3DDevice)
    ,m_RefCount(1)
    {
    
    }
    
    D3D9WrapperUnknown:: ~D3D9WrapperUnknown
    {
        if(m_RefCount>0)
        {
            //输出信息,因为错了。
        }
    }
    
    ULONG D3D9WrapperUnknown::AddRef( )
    {
        return ++m_RefCount;
    }
    
    ULONG D3D9WrapperUnknown::Release( )
    {
        if(!(--m_RefCount))
        {
            if(m_pUnknown != NULL)
            {
                m_pD3DDevice->SubmitRelease(m_pUnKown);
            }
            delete this;
        }
        return m_RefCount;
    }

    这个技术达到了本文介绍中的所有目标,并且也是最终的选择方案。D3D9渲染系统被选择来实现这个方案,伴随本文的代码可以从Intel Developer website上下载。

    OGRE分析以及结果


    这个部分我们将讨论采用“低级线程化手段”时,运行OGRE的例子程序的情况。下面的数据是在2.4GHz Core 2 双核处理器,NVIDIA 7800显卡上测试的结果。这些数据显示了两个不同的缓冲加锁方案,以及在没有开启多线程时DirectX包装类的负载。绿色背景指明了使用这个方案时提升到了1.1倍,甚至更多。 黄色部分指明了运用该方案时,降低到了1.0倍以下,但是并无太大损耗。而红色部分指明了动用此方案时,出现了冲突情况。

    clip_image018

    注意,这里有太多的1。0左右的元素,当然也有一些稍微低于1。0的。但是不会太多。可以看到,这里也有许多绿色的元素,表明运行良好。但要注意,这些测试例子都是OGRE的DEMO,DEMO并不使用CPU处理太多的东西。只有两个元素是红的。都是处于“完全缓冲加锁”方案中。因为这些DEMO经常更新很大的顶点缓冲。导致很多时间花费在等待复制完成,从而出现问题。

    需要特别说明的是“Shadow Demo”。有两种阴影方案,模版和纹理。只有模版阴影在“部分缓冲加锁”方案时会出现问题,而纹理阴影则工作良好。

    同时也要注意的时,一些例子在“完全缓冲加锁”方案中运行的效果比“部分缓冲加锁”方案要好。在这种情况下,应用程序取得一个可以读写的缓冲区。在这种情况下,主要是负载在于应用程序会将显存中的数据复制到系统内存中。而“完全缓冲加锁”方案通过将加锁和解锁放到渲染线程中,而将这个负担消除了。

    结论


    将一个3D渲染引擎线程化是一项具有挑战性的工作,但事实证明,它是可行的。正如先前所展示的一样,将OGRE渲染引擎线程化是很成功的,并且在某些DEMO上运行出了良好的效果。有三种线程化方案,但最终只选择了“低级优化方案“来实现。因为其满足了本文最初提出的目标。在OGRE社区的共同努力下,OGRE可以在CPU和GPU资源都密集型的应用程序中,运行出更多帧数和更平滑的显示效果。

  • 相关阅读:
    dirname,basename的用法与用途
    终极解决方案——sbt配置阿里镜像源,解决sbt下载慢,dump project structure from sbt耗时问题
    linux-manjaro下添加Yahei Hybrid Consola字体
    Idea无法调出搜狗等中文输入法
    Spring 源码学习系列
    BF算法
    Mybatis Mapper接口是如何找到实现类的-源码分析
    Lua脚本在redis分布式锁场景的运用
    GO语言一行代码实现反向代理
    SpringMVC源码分析-400异常处理流程及解决方法
  • 原文地址:https://www.cnblogs.com/qilinzi/p/1910748.html
Copyright © 2020-2023  润新知