文章目录
一、多线程开发技术概述
多线程开发是指从软件或者硬件上实现多个任务并发执行的技术,每个正在系统上运行的程序都可以视为一个进程Process,每个进程包含一个或多个线程Thread。
线程可以在程序中独立执行,轻量版进程。
- 多线程的调度和执行通常由操作系统负责完成,对于单CPU的系统而言,每个线程各自分配一段时间片,保证他们轮流提交到CPU进行处理;而多CPU则可以由多个处理器负责完成不同的任务线程。
- 虽然线程执行开销较小,但是不利于数据的管理和保护,由此线程的同步特别重要。
- 线程同步研究了多线程之间执行顺序和数据共享的问题:几个线程之间往往存在数据竞争,即他们同时读写一处内存数据,导致执行结果的混乱或者内存冲突。
- 线程同步方式:互斥体Mutex,栅栏Barrier,阻塞器Block,条件量Condition,信号灯Semphore,管道Pipe,消息Message
1.OpenThreads
- Thread线程实现类
通过派生Thread类并重新实现run和cancel成员函数(实现线程运行时和取消时的操作),调用start和cancel(启动或中止已经定义的线程对象),就相当于定义了一个共享进程资源但是可以独立调用的线程。
函数 | 功能 |
---|---|
static int YieldCurrentThread(); | 要求当前线程出让CPU控制权,交给其他正在等待的线程 |
int start(); int startThread(); | 启动线程,此时将自动开始执行线程的run()函数 |
virtual int cancel(); | 虚函数,用于中止线程的执行 |
bool isRunning(); virtual void run() = 0; | 线程执行的主函数。在这个函数中可以循环执行一段线程功能代码,但是最后或者析构中一定要使用YieldCurrentThread出让CPU控制权 |
int setProcessorAffinity( unsigned int cpunum ); | 对于多处理器的系统,设置线程所在的CPU位置 |
- Mutex互斥体接口
某一个线程想要操作某一共享资源时,首先使用互斥体成员lock()函数加锁,操作完成后在使用unlock()函数解锁。一个线程可以存在多个Mutex,但是要注意解锁。
-
ScopedLock模板类,与Mutex配套使用,在其作用域内将对共享资源进行自动加锁,作用域之外则自动解锁:
{OpenThreads::ScopedLock<OpenThreads::Mutex> lock(mutex);}
-
Condition条件量接口
- 依赖于某个互斥体mutex,互斥体加锁时Condition阻塞所在线程,解锁或者超过时限则释放此线程,允许其继续运行。
- 线程同步是指一个进程多个线程可以协调工作,例如让他们都在指定的执行点等待对放,直到成员到齐后才开始运行。
- 阻塞指强制一个线程在某个执行点上等待,直到满足继续运行的条件为止,例如某个变量初始化完成,其他线程到达同一个执行点,而条件一般通过条件变量来设置各种条件。
函数 | 功能 |
---|---|
int wait(Mutex* mutex) | 设置作为条件量的互斥体,并强制线程等待此条件满足 |
int signal(); int broadcast(); | 唤醒一个线程,或唤醒所有被阻塞的线程 |
- Block阻塞器类
用于·阻塞线程,使用block()阻塞它所在的线程(注意不一定是定义block的Thread线程,而是当前执行了block函数的线程,包括系统主进程),并使用release释放之前被阻塞的线程。
- 下面例子就是:运行程序后可以发现,Block::block()函数将首先阻塞主进程,被释放后再次阻塞的是TestThread线程,这与它是谁的成员变量并无关系。
- Barrier线程栅栏
可以设置一个整数值,相当于栅栏的强度。每个执行了Barrier::block()函数的线程都将被阻塞;当被阻塞在栅栏处的线程达到指定数目时,栅栏将被冲开,所有线程几乎被同时释放,保证了线程执行的同步性。
函数 | 功能 |
---|---|
bool block(unsigned int) ; | 阻塞当前的线程。如果超过栅栏的强度(可以在这里重新设置强度),则自动释放所有的线程 |
- BlockCount计数阻塞器类
它与阻塞器类的使用方法基本相同:block()阻塞线程,release()释放线程;不过除此之外,BlockCount的构造函数还可以设置一个阻塞计数值。计数的作用是:每当阻塞器对象的completed()函数被执行一次,计数器就减一,直至减到零就释放被阻塞的线程。
注意BlockCount与Barrier的区别,前者是由其它任意线程执行指定次数的completed()函数,即可释放被阻塞的线程;而后者则是必须阻塞指定个数的线程之后,所有的线程才会同时被释放。
要注意在适当的地方添加Sleep,因为单CPU线程按时间片执行的。
C语言:
参数1:start_address为线程函数的地址,这个参数即函数名
参数2:stack_size,新线程的堆栈大小;设为0表示与主线程使用一样的堆栈
参数3:arg_list,参数列表(没有参数时为NULL)
比较win创建线程和openThread创建线程
// C
#include <process.h>
#include <windows.h>
#include <iostream>
int tickets = 10;
HANDLE iMutex ;
void sellTickets1(void *ptr)
{
while(tickets>0)
{
WaitForSingleObject(iMutex ,INFINITE);//等待信号量
Sleep(10);// 实现线程交替执行的效果
std::cout<<"Thread 1 sell:"<<tickets<<std::endl;
tickets--;
// 执行完要释放锁
ReleaseMutex(iMutex );
}
}
void sellTickets2(void *ptr)
{
while(tickets>0)
{
WaitForSingleObject(iMutex ,INFINITE);//等待信号量
Sleep(10);
std::cout<<"Thread 2 sell:"<<tickets<<std::endl;
tickets--;
ReleaseMutex(iMutex );
}
}
int main()
{
HANDLE t1 = (HANDLE)_beginthread(&sellTickets1,0,0);
HANDLE t2 = (HANDLE)_beginthread(&sellTickets2,0,0);
iMutex = (HANDLE)CreateMutex(0,FALSE,0);//
Sleep(2000);
return 0;
}
//OpenThread 重载自己的run函数,调用start接口
#include <windows.h>
#include <OpenThreads/Thread>
#include <OpenThreads/Mutex>
#include <OpenThreads/ScopedLock>
#include <OpenThreads/Barrier>
#include <iostream>
OpenThreads::Mutex mutex;//全局
OpenThreads::Barrier bar;
class ThreadSelf : public OpenThreads::Thread
{
public:
ThreadSelf(int a){threadid = a;}
~ThreadSelf()
{
while(isRunning())
{
OpenThreads::Thread::YieldCurrentThread();
}
}
virtual void run()
{
OpenThreads::ScopedLock<OpenThreads::Mutex> lock(mutex);
int count =10;
while(--count)
{
Sleep(10);
std::cout<<"Thread print:"<<threadid <<std::endl;
}
bar.block(3);//线程释放壁垒
}
int threadid;
};
int main()
{
ThreadSelf t1(10);
ThreadSelf t2(6);
t1.start();
t2.start();
//std::cout<<"Here"<<std::endl;//直接打出来了,主
//实现前几个线程完成,再打印出来。
bar.block(3);// 主线程1个,小线程2个
// 当存在3个线程都执行该语句之后,将冲破栅栏,这样子三个线程会同时被释放
// 两个线程分别执行一次,加上main中执行一次刚好3次。
std::cout<<"Here"<<std::endl;
Sleep(1000);
return 1;
}
其实在osgViewer::Viewer中,就有接口void osgViewer::ViewersetThreadSafeRefUnref(bool threadSafe)来设置线程安全。
// Condition C语言
// 即第二个线程一定在第一个线程之后
#include <iostream>
#include <process.h>
#include <windows.h>
int condition = 0;
void setCondition(void *ptr)
{
condition = 1;
}
void ifCondition(void *ptr)
{
if(condition)
{
std::cout<<"Condition is find"<<std::endl;
}
}
int main()
{
// test 此时并不会打印
//HANDLE t1 = (HANDLE)_beginthread(&ifCondition,0,0);
//Sleep(100);
//HANDLE t2 = (HANDLE)_beginthread(&setCondition,0,0);
// test 以上部分,并不会打印
// 以下部分才会打印:
HANDLE t2 = (HANDLE)_beginthread(&setCondition,0,0);
Sleep(100);
HANDLE t1 = (HANDLE)_beginthread(&ifCondition,0,0);
Sleep(1000);
return 0;
}
// Condition OpenThreads
// 即第二个线程一定在第一个线程之后
#include <iostream>
#include <process.h>
#include <windows.h>
#include <OpenThreads/Condition>
int condition = 0;
OpenThreads::Condition cond;
OpenThreads::Mutex mutex;
void setCondition(void *ptr)
{
condition = 1;
cond.signal();//释放信号量
}
void ifCondition(void *ptr)
{
cond.wait(&mutex,INFINITE);//和signal对应
if(condition)
{
std::cout<<"Condition is find"<<std::endl;
}
}
int main()
{
// 会打印
HANDLE t1 = (HANDLE)_beginthread(&ifCondition,0,0);
Sleep(1000);
HANDLE t2 = (HANDLE)_beginthread(&setCondition,0,0);
Sleep(1000);
return 0;
}
2.OSG操作线程——osg::OperationThread
- 派生自OpenThreads::Thread。重构了线程类的成员run(),并在其中依次完成各个用户操作的调度和执行。
- OperationThread可以接受一个操作队列的内容,并依次执行其中的自定义操作回调函数;
- 而其子类GraphicsThread则用于图形渲染操作的多任务处理过程。
- 用户操作可以是完成某项工作,也可以是同步各个线程:
- 自定义一个用户操作的方法是继承Operation类并重构其核心函数:
- void osgViewer::ViewersetThreadSafeRefUnref(bool threadSafe)可以设置线程安全。
GraphicsThread派生自OperationThread
- 只是重写了run函数的内容,它同样需要传入多个用户操作对象Operation,以及在线程运行时依次执行这些对象。
- 该线程的用户操作中可以执行OpenGL相关的图形操作,因为它的实例线程专用于OpenGL图形相关的操作,因此线程会自动在每执行一个用户操作之前指定一次OpenGL渲染设备,并在执行完成后重置:
- OSG中具备自行创建线程功能的类包括GraphicsContext和Camera。GraphicsContext可以创建一个设备图形线程,用于实现多设备并发的绘制操作。Camera可以创建一个相机线程,将场景裁减的任务分离,从而实现多相机并发裁减与多设备并发绘制的协同操作。
二、基本场景渲染流程
主要介绍场景渲染过程,也就是OSG渲染树的构建、渲染树的遍历和渲染过程,以及渲染后台与OSG用户前端的接口,帮助高层次的用户深入思考渲染引擎的构架方式,进而完善和实现自己的引擎设计方法。
还将介绍OSG目前提供的4种多线程渲染方式的实现。
1.OSG状态机:osg::State
- 几乎封装了所有的OpenGL状态量、属性参数,以及顶点数组的设置。
- 用户开发时对于渲染状态集StateSet(模式和属性的合集)、几何顶点数据Geometry的操作,实质上最终都是交给State类来保存和执行的,它提供了对OpenGL状态堆栈的处理机制,因而开发者不必反复考虑OpenGL堆栈处理的问题;同时还允许用户直接查询各种OpenGL状态的当前值,直接执行State::captureCurrentState,而不必使用glGet系列的OpenGL函数。
- 如果说CullVisitor类是场景裁减的关键部件的话,那么State类就是场景绘制的核心,类似于状态机的方式,保存了一些重要的场景即时状态数据,包括:
- 当前的模型视点矩阵、投影矩阵
- 当前应用的顶点数组,法线数组,纹理数组列表,顶点属性数组列表等,以及当前绑定的VBO,EBO和PBO对象。
- 一个渲染属性StateAttribute的映射表,以及一个纹理渲染的映射表。以属性类型为映射关键字,保存了每个渲染属性的数据堆栈。堆栈的入栈顺序和各级节点的渲染属性设置顺序一致。
- 一个渲染模式开关StateAttribute::GLMode的映射表,以及一个纹理渲染模式的映射表。以模式枚举量为关键字,保存了每个渲染模式开关的值堆栈。堆栈的入栈顺序和各级系欸但的渲染模式设置顺序一致。
- 一个GLSL一致变量Uniform的映射表。以变量名称为关键字,保存了每个一致变量的数据堆栈。堆栈的入栈顺序和各级节点的Uniform设置顺序一致。
- 一个渲染状态集的堆栈。它按照各级节点设置的StateSet顺序进行入栈和出栈处理,因此栈顶的数据一定是当前所设置的渲染状态集。
- 在前面介绍过Drawable::drawImplementation,就是可绘制体的绘制执行函数,其内部就是使用State类的相关成员函数实现了OpenGL顶点数组机制(glVertexPointer等),以及VBO的设置和绑定功能(glBindBuffer等),同理OpenGL渲染属性和模式的设置也可以通过State类的成员来完成,例如applyMode:
而设置渲染属性的applyAttribute函数,其实质与之类同。
- 使用State::pushStateSet将渲染状态集入栈时,系统将同时把其中包含的每个渲染属性、渲染模式和一致变量记录到对应的映射表中,保存至数据堆栈的栈顶。
- popStateSet函数的工作与其正好相反,他从每个映射表中取出数据堆栈,并弹出栈顶的数据。
- 因此,如果状态机State的入栈和出栈动作是随着OSG状态树的遍历过程进行的,那么它所保存的所有当前状态都会与状态树的当前状态节点StateGraph相对应。
- 如果此时执行applyMode和applyAttribute函数,应用栈顶的状态数据到渲染管线中,那么所有OpenGL状态设置值将同样与状态树的当前状态节点相对应。
- 而状态树的末端渲染叶RenderLeaf负责几何体、位图或者其他可绘制对象的渲染函数的调用;此外,它还预先取出并应用当前可绘制体对应的空间位置和渲染状态信息,流程如下:
- 其中Drawable::draw()是drawImplementation的执行者:
而此处他们的状态树的渲染叶予以调用(每一帧)。 - 状态树的遍历函数moveStateGraph则主要完成了下面的一些工作:
由此可见:OSG状态树(包括状态节点和渲染叶)的遍历过程,等同于将场景的渲染状态以及应用这些状态和变换矩阵的可绘制依次传递到OpenGL渲染管线的过程。
裁减访问器CullVisitor遍历场景节点树的过程就是筛选和构建状态树的过程;
而渲染叶遍历状态树的过程就是交由OSG状态机执行OpenGL指令,就是真正绘制场景的过程。
下面将介绍如何管理和使用渲染叶,以及如何高效和灵活地控制场景的绘制工作。
2.构建场景渲染树
- OSG系统后台使用状态树来管理实际用于渲染的状态与数据,同时还是用一个渲染树来完成渲染叶RenderLeaf的实际绘制。
- 这里所说渲染树概念可能会令人迷惑,因为上文RenderLeaf::render函数本身的工作就是通过遍历状态树来设置OpenGL的渲染状态,然后再执行可绘制体的实际绘制。
所以对于一个不太严谨的渲染后台实现而言,可以直接收集所有已构建的RenderLeaf对象再一一执行它们的render函数,就可以实现场景中所有对象的状态变更和渲染了。
例如对于一个渲染叶列表renderLeafList,可以按下代码来渲染所有渲染叶数据:
渲染树
osg还提供渲染顺序调整、状态路径优化等一系列改善渲染性能的附加功能,都是靠"渲染树"。
渲染树有两类节点:一类是根节点,名为渲染台;另一类是分支节点,名为渲染元。其中,渲染台是渲染元的派生类,它同时承担了渲染树的管理和绘制任务。
渲染台RenderStage和渲染元RenderBin(osgUtil库)
- 其中状态节点列表是CullVisitor::apply(Geode&)函数执行过程中使用addStateGraph得到的,由于此时获取的状态节点均有场景Drawable对象所对应的渲染状态构建,因此它们也必然包含与Drawable对象绘制数据(顶点图元,位图等)相对应的渲染叶RenderLeaf,因而渲染元也就拥有了一个完整的渲染叶列表。
- 即从状态树转换到渲染树
- 对于一个没有特殊设计要求的场景来说,其“渲染树”往往只有一个渲染元节点,也就是渲染台根节点。场景执行渲染时,渲染台节点即执行每一个渲染叶的render函数,完成状态树的遍历、OpenGL状态的执行、以及几何顶点和属性的绘制工作。
渲染树中的应用:HUD和天空
- 其中改变场景中某个相机及其子场景树的渲染顺序,使它在主场景之前或者之后渲染,可以使用camera::setRenderOrder
相机的渲染顺序直接影响到渲染树根节点RenderStage的个数
当场景中存在多个前序或后序渲染的相机时,就可能存在多个与之对应的渲染台根节点RenderStage:他们按照预定的先后顺序,组织内部的渲染叶RenderLeaf依次进行渲染。
渲染台的子节点就是渲染元,实际上也是在裁剪访问器遍历场景树的时候生成的。
- 由此可以看出,只有之前使用setBinName设置了某个渲染状态集的类型,才会从当前渲染树的根节点分支出新的子节点渲染元renderbin,并且对应状态子树的渲染叶均会追加到这个渲染元当中来。
- 其中render实际上是通过SceneGraph::moveStateGraph和State::apply改变当前的OpenGL渲染状态,然后调用Drawable::draw绘制物体。
- 而渲染树中所有渲染叶按照一定顺序依次进行绘制的过程,就是OSG的场景节点结构转换为OpenGL状态机制,并进行绘制的过程。
3.渲染树的优化排序
- osg提供对渲染叶列表进行优化排序的相关函数,可以实现对保存在同一个渲染元中的各个渲染叶数据可再一次按照一定的顺序进行重新排列,从而改变各个可绘制体的渲染顺序,以实现更为精确的控制。
- setBinName和setRenderingHint设置对应渲染元的类型,不同类型的渲染元会执行不同的排序方案,这就是renderbin::sort的实现过程。
4.范例::广告牌森林(补
三、多种线程模型的讨论与实现
OSG提供了4种不同的渲染引擎多任务模型,以满足各种硬件条件的需要,最大限度地发挥CPU和图形设备的潜力,降低不必要的性能损耗。
1.渲染器Renderer和场景视图SceneView
- 场景节点树是通过场景中的一个或多个相机节点camera来进行管理的,不属于任何相机的场景节点无法用于渲染过程;
- 图形设备gc则表示相机的底层图形窗口或者缓存,并且传递了各种各样的用户交互事件;
- 视图view和视景器viewer/CompositeViewer负责将相机节点及其子树应用于系统的仿真过程,并加载漫游器、交互事件处理器和各种场景浏览的辅助部件。
- 而系统前端(由上面这些共同完成的,还完成了场景数据的管理)与渲染后台的接口,则通过渲染器Renderer和场景视图SceneView完成:
渲染台renderstage由相机节点决定,渲染元由状态节点stagegraph的状态集stateset决定;
并且,当向场景添加一个新的相机Camera时,一个与之关联的渲染器Render也会被自动创建,用于为相机节点与渲染台之间提供一个公有接口。当我们准备执行场景的裁剪和绘制时,渲染器会负责传递场景与用户数据,进而交由系统后台执行裁剪与绘制工作了。
渲染器Render::Operation,线程操作类
-
渲染器的工作是创建并初始化场景视图,每帧更新场景视图SceneView的数据,以及执行裁减和绘制(SceneView才是真正执行者)。
-
渲染器负责的工作(裁减/绘制/裁减和绘制)不同,所实现的多线程工作模式也就不同。
场景视图SceneView:裁减和绘制工作的真正执行者
渲染器并没有直接将场景节点传递到裁减访问器,也不负责记录渲染树或状态树的节点数据,它自动创建并保存的osgUtil::SceneView对象。
- SceneView需要获取所有场景信息,并传递到系统后台。
这里有机会先后执行4次相机回调,它们的调用时机各不相同,具体的自定义功能实现可以根据用户的实际需求而定。
RenderInfo:渲染信息
记录了某个对象渲染时所需的一切信息,因此作为Drawable::drawImplementation的参数出现。
其内容每一帧都会更新,开发者可以随时取出其中的数据并作为场景绘制的依据,例如添加一个前序或后续callback,在其重载函数operator中每一帧都能获得Renderinfo。
OSG引擎的核心渲染机制概括
2.单线程模型
- osg中,用户更新阶段包括视景器的eventTraversal和updateTraversal阶段,其工作包括人机交互事件的收集和整理、场景漫游器和事件处理器的更新,以及用户自定义节点回调(使用setEventCall back和setUpdateCallback设置)更新;。
- 裁减阶段和绘制阶段的实现均在renderingTraversals中完成,对于单线程模式,可以用一下伪代码表示:
viewer.setThreadingModel(osgViewer::ViewerBase::SingleThreaded);
3.多图形设备裁减/绘制模型
缺点是它假定用户更新阶段Update阶段不会过于繁琐,因而主要把精力放在裁减/绘制过程,并行优化上;
所以如果在一帧中执行大量的数据更新和人机交互操作,甚至大量和三维场景无关的动作,则可能会造成教严重的帧延迟现象,因为这一帧的裁减/绘制操作将不得不延后相当长一段时间再执行。
4.多图形设备绘制模型
5.多相机绘制模型
6.数据变度
设置为动态的场景节点,其所有子节点和可绘制对象都是动态的。