游戏引擎多线程一.docx
《游戏引擎多线程一.docx》由会员分享,可在线阅读,更多相关《游戏引擎多线程一.docx(12页珍藏版)》请在冰豆网上搜索。
![游戏引擎多线程一.docx](https://file1.bdocx.com/fileroot1/2022-12/14/b7b4c840-b66a-4f58-9c95-9755f364a032/b7b4c840-b66a-4f58-9c95-9755f364a0321.gif)
游戏引擎多线程一
游戏引擎多线程
(一)
前言
最近一直在做项目优化,可是由于项目引擎历史原因,不能去砍掉某些功能而去优化项目,项目开发到这种程度,只能在这个基础上去提升整个引擎的效率,常规的CPU和GPU上的优化(美术资源上的缩减,CPU上耗费地方和GPU耗费地方的优化等)基本上都做了。
当然每个人都希望自己的游戏跑的越快越好,现在大部分机器都已经至少是双核的,如果能发挥多核优势,游戏的速度会大幅提升。
这里只是谈游戏引擎的多线程,至于游戏逻辑和游戏引擎这面关联不大。
游戏中大部分线程一个是用来每帧更新,一个是资源加载。
资源加载本文不谈,但下面设计的多线程架构会考虑多线程加载的情况,让多线程加载无缝对接。
多线程模型
下面一共想了2种多线程框架,这2种都不是那种无休止的那种让每个线程都疯狂的运行,这样处理起来会有很多棘手的问题,为了简化问题,需要每帧都去同步一次这些线程,这样在提升效率的同时也简化问题的复杂程度(其实这2种模型原理基本一样,实现细节不同,因为一个是渲染,一个是纯引擎更新)。
游戏引擎一把流程分成下面这几部:
流程1.游戏物体的update
流程2.Cull阶段,这个阶段包括相机对物体的裁剪
流程3.对可见物体的渲染分类
流程4.那些可见并且依赖相机更新的物体进行更新。
流程5.渲染
可能不同游戏引擎流程处理和上面不太一样,但大多数都差不多。
这里面每一个过程都是相互依赖的,上一个流程输出,是下一个流程输入,一般只要是相互依赖的,要想做多线程处理,每一帧去同步的话,都要有2个buffer,上一个流程用一个buffer把上一帧的结果记录下来,下一个流程去取另一个buffer进行出来,然后帧末或者帧前交换2个buffer。
如果每个流程都去这么做,随着流程越多,本来是延迟一帧的做法,随着流程的增多,会延迟很多帧,并且,好多东西都是不固定的,buffer来存放也是很棘手的问题。
现在为了避免这些问题,提出2种多线程模型,虽然不能让每个流程去多线程出来,但也可以尽量发挥多个CPU的能力,总之比单一线程来跑还是快的。
模型1
2013-1-511:
43:
41上传
下载附件(33.04KB)
模型1的机制其实很简单,把渲染部分单独拿出来,但由于渲染部分和上面流程是相互依赖的,这个时候必须用双缓冲buffer,做延后一帧处理。
也就是上面说过的,一个buffer是给主线程用来填充的渲染数据,另一个是用来给渲染线程来渲染的,然后再步骤2的时候同步2个线程
模型2
2013-1-511:
50:
39上传
下载附件(54.21KB)
模型2比模型1的改进就是把流程1分解成多个线程处理,一般游戏里比较消耗的更新就是骨骼动画和粒子的更新。
如果更新是没有依赖关系的,就可以把它放到一个单独线程里来出来,如果update m 和update n 有依赖关系,但update m 和update n的集合和其他没有依赖关系,那么就把他们放到一个里面去更新。
要把这个划分出来除了设计上就要考虑最小依赖,还要去考虑依赖程序,才能准确划分。
举个例子,有些粒子是绑定到骨头上的,骨头的更新后才能粒子更新,因为粒子要跟随的,如果再无其他更新依赖那么就可以把它们弄到一个线程去。
有些粒子不绑定到骨头上,而且这样就可以把它弄到一个线程去。
游戏世界中每一个Actor的更新保持独立性,这种独立性是需要制约的,因为我们很难保证Actor的独立性,要保证独立性并不是太难,需要做一些额外的工作。
我举个例子比如,场景中有2个物体,一个游乐场里面的旋转木马和一个人,默认情况下,人不在车上,并且旋转木马还在转,人的更新和木马的更新是毫无依赖关系的,他们每一个都可以单独放到一个线程去更新。
这个时候如果人上了旋转木马,人就要跟随着旋转木马来动,简单的只是位置变,复杂的可能人被绑定到旋转木马的骨头上,跟着骨头走。
这个时候你就不能把他们分别放到一个线程去更新,你要把他们放到同一个线程去更新,把他们看做一个整体。
一般情况下,每个Actor只是逻辑数据,它的实际渲染数据作为一个node封装在Actor里面,因为我们考虑的是引擎的多线程,不考虑逻辑层面,当人和旋转木马独立的时候,人和旋转木马的node都attach在引擎里的world,而人和旋转木马的actor都在逻辑层面的world,
如果要想很好解决这个问题,我们就要让直接attach引擎world里面node都是独立的,那个node不独立于另一个,则这个node必须detach下来,在重新attach到另一个node上。
就刚才的例子,人的node要想上旋转木马,必须从引擎world上detach下来,然后再重新attach木马的node上,这样引擎world只剩下一个旋转木马node,当旋转木马node更新的时候,人的node作为子节点,去更新。
这就保证了直接attach引擎world里面node都是独立的,而逻辑层面还是没有变。
还有一种方法就是,你在更新前,把所有相互依赖的归类,这个相对引擎而言,改动是比较小的,需要添加代码就可以/
还有一种就是把骨骼动画,粒子独立出来。
但这种方法,需要考虑的因素可能会很多,粒子和骨头都有可能一个绑在另一个上,谁先更新呢?
这个你又要多费劲,去想方设法的改你架构分几种情况,而且有可能还要做帧同步等等,十分麻烦。
其实流程2也可以独立出来做多线程,因为场景里面会有多个相机,把每个相机的更新仍给一个线程,但必须等流程1所有都更新完毕,只需要同步一次即可。
然后等所有流程2更新完毕,再同步一次,再去做流程3,4.。
这里只是说了大体架构和方法,其实模型2和渲染是没有关系的,但相对渲染来讲是很简单的,如果渲染多线程处理好了,非渲染上的多线程很好处理,所以这里只给出了原理,没有去实现。
还有实现的时候,对于更新开辟一个线程就可以了,毕竟CPU核心是有限的,你是目的是让你的CPU跑满,而不是让它喘不过气,所以尽量不要用线程池。
但如果你更新要用多线程,裁剪又要多线程,他们不可能同时运行,这个时候你就可以设计一个好的线程池,不去浪费线程资源。
多线程渲染详细的解决方案。
准备工作
在做多线程渲染之前,确实做了好多准备工作。
以前没有做过多线程的大量的代码,只是些过一些小的DEMO,大学里面学的《操作系统》确实给了最主要帮助,我还清晰记得PV操作是《操作系统》课程的一个核心章节,虽然windows编程里面有了event概念,但原理其实都是一样的,而且无论是关键区,互斥量,信号量,其实它们都是《操作系统》课程的信号量。
再一个要提的就是“原语”,指的就是在执行过程中是不可以打断的,例如j=i +1这个变成汇编指令根据硬件的不同可能是1条指令,也可能是2条指令(至少要一个add指令 和一个 mov 指令)如果2条以上指令,那么它就是可以被操作系统打断,它所在线程挂起,这里之所以提及这个,就是因为,有时候,你设计认为它没有被打断,它运行其实不对,其实它是被打断的,如果运行对了,那是你运气好。
上面只是最基础的东西,多线程渲染比较复杂在,它和D3D要打交道,自己以前也想过怎么处理各种复杂的情况,上网查过很多资料,也看过别人写的多线程的demo。
不得不说,有些确实可以解决多线程渲染问题,但集成复杂度太高,一种情况做一种处理,这肯定要累死你,还有的只给理论没有任何细节的东西,更别说demo,这种能不能做成其实都很让人去怀疑。
归类始终是解决问题最好的办法,找到问题相似点,然后统一处理,但这个相似点似乎不是那么难找到。
unreal的出现,问题有了起色,但只能说起色,因为这种方法好多地方其实都在用,只不过unreal编码方式和特别,用宏封装了起来,还有一个最重要的方式,它把执行的代码包装到了类成员的成员函数(好多用类似commad的多线程,都用函数指针了,其实那种方式很乱,参数都都自己做了一个栈传来传去的)。
更更重要的是,它把多线程渲染实现了,而且还用到大型项目里面,这其实是最有说服力的,因为有时候一个理论提出来,你没有大规模应用,别人肯定会怀疑你的理论,而你一旦实现了,他不会怀疑你的理论而是怀疑自己的脑子了。
Unreal这种方法虽然好,但集成复杂度很高,你需要了解你自己现在引擎很多东西,而且还要考虑线程安全问题,后面我会说道这些问题。
多线程渲染
为了简化问题,不可能让主线程和渲染线程毫无次序的运行,所以采用帧同步,让他们每帧去同步一次。
主线程提交数据,渲染线程处理数据,当然这很容易就想到生产者和消费者的模式,这种模式需要有数据存放的地方,如果使用一个buffer存放数据,这个buffer的所有操作要用异步处理,防止2个线程同时对它进行操作,主线程有数据就放到这个buffer中,buffer里面只要有数据渲染线程就不停的处理。
还有一种是用2个buffer,一个是主线程提交数据的buffer,一个是渲染线程处理数据的buffer,每帧结束后,交换2个buffer。
使用2个buffer会多一些存储空间,但不会因为异步访问阻塞任何一个线程运行,而且只要渲染数据资源本身不是需要额外的空间,其实是不会浪费很多存储空间的,而且设计的好坏也会避免这样问题出现。
大体的框架就是:
每帧开始的时候,主线程唤起渲染线程,2个线程一起运行,渲染线程处理完所有数据后会激活用来同步的的event,然后进入无限循环的状态,主线程去wait这个event,如果渲染线程处理完了所有数据,主线程就不会被wait卡住,如果主线程先提交完数据,就会被wait卡住。
一旦主线程通过wait就挂起渲染线程,然后处理同步信息,包括交换2个buffer等。
接下来是细节问题,这个处理数据buffer要怎么设计。
采用unrealrendercommand作为buffer的基本成员,把每个要处理的数据封装成命令的形式,实际上就是一个类的实例,根据不同类型的要处理的数据,创建不同的类,然后实例化这个类,加入这个buffer中。
为了简化这个过程,unreal用一系列宏来封装了起来,提升了开发速度。
#defineENQUEUE_RENDER_COMMAND(TypeName,Params)\
{\
check(IsInGameThread());\
if(GIsThreadedRendering)\
{\
FRingBuffer:
:
AllocationContextAllocationContext(GRenderCommandBuffer,sizeof(TypeName));\
if(AllocationContext.GetAllocatedSize() {\
check(AllocationContext.GetAllocatedSize()>=sizeof(FSkipRenderCommand));\
new(AllocationContext)FSkipRenderCommand(AllocationContext.GetAllocatedSize());\
AllocationContext.Commit();\
new(FRingBuffer:
:
AllocationContext(GRenderCommandBuffer,sizeof(TypeName)))TypeNameParams;\
}\
else\
{\
new(AllocationContext)TypeNameParams;\
}\
}\
else\
{\
TypeNameTypeName##CommandParams;\
TypeName##Command.Execute();\
}\
}
#defineENQUEUE_UNIQUE_RENDER_COMMAND_ONEPARAMETER(TypeName,ParamType1,ParamName1,ParamValue1,Code)\
classTypeName:
publicFRenderCommand\
{\
public:
\
typedefParamType1_ParamType1;\
TypeName(const_ParamType1&In##ParamName1):
\
ParamName1(In##ParamName1)\
{}\
virtualUINTExecute()\
{\
Code;\
returnsizeof(*this);\
}\
virtualconstTCHAR*DescribeCommand()\
{\
returnTEXT(#TypeName);\
}\
private:
\
ParamType1ParamName1;\
};\
ENQUEUE_RENDER_COMMAND(TypeName,(ParamValue1));
我只列出带一个参数的宏,通过ENQUEUE_UNIQUE_RENDER_COMMAND_ONEPARAMETER这个名字可以看出来,还有没有参数的,2个和3个的,其实只需要无参数和1个参数就足够,大多数参数封装到一个结构体里面作为一个整体,当作一个参数。
#defineENQUEUE_UNIQUE_RENDER_COMMAND_ONEPARAMETER(TypeName,ParamType1,ParamName1,ParamValue1,Code)
其实把你要用的代码展会后这个宏什么意思一目了然。
这个宏是定义了一个类,有一个参数,有个构造函数,同过外部的变量来赋值给里面的类成员变量。
Execute()这个是你要执行的代码,Code这个也是从宏传过来的。
j=j+1;
ENQUEUE_UNIQUE_RENDER_COMMAND_ONEPARAMETER(Add,int,i,j,i++;)
ClassAdd:
publicFRenderCommand
{
public:
typedefint_ParamType1;
Add(const_ParamType1&Ini):
:
i(Ini)
{
}
virtualUINTExecute()
{
i++;
returnsizeof(*this);
}
virtualconstTCHAR*DescribeCommand()
{
returnTEXT(“Add”);
}
private:
inti;
}
在写宏的时候有一个变量j,在创建实例的时候,这个j就是构造函数的参数。
#defineENQUEUE_RENDER_COMMAND(TypeName,Params)\
这个宏是用来创建这个类的实例,然后把这个实例放入渲染队列,这可以看出这个如果是多线程渲染,则在一个内存空间中来创建这个实例,加入渲染队列,空间大小不够则加大空间爱你,大体意思就是这样,如果不是多线程,则创建完实例直接运行。
这里就出现一个很大的问题,这个东西要怎么使用,什么时候去使用,使用的时候要注意什么。
继续上面的例子ENQUEUE_RENDER_COMMAND(Add,j);我这里就不全展开了,你展开就会得到实例化的代码。
他的本意是把这些要处理的都扔到渲染线程去计算,先来看看unreal是怎么使用的,你搜索这个宏,可以看到,unreal在这上面的使用貌似没有什么成型的规范,大到整个裁减渲染,小到一个buffercopy都被扔到渲染线程,唯一有点共性的就是这些都或多或少,或深或浅的和D3D有些关系,但这么说有点牵强,整个引擎都和D3D有关系,它也整个可以扔到渲染线程,如果你组织合理的话,确实是可以的,但效率能不能保证是另一个马事。
再看下一个问题,使用这个宏的时候,要让使用者必须对封装的代码及其了解,因为这些要扔到渲染线程,必须要注意,这里面很可能有些数据是要被主线程使用,就可能涉及到线程安全问题,导致出各种诡异的问题。
举一个例子:
处理模型的骨骼问题,在渲染之前,要先更新骨架,把层级数据算好,把蒙皮信息的矩阵都要算好,渲染的时候把蒙皮的矩阵给VERTEXSHADER,这个时候就涉及到线程安全问题,这个存放蒙皮矩阵的地方,你要单独拷贝出来,存放然后让渲染线程来使用,这样才不会有线程安全的问题,保证渲染的时候这个数据不会被破坏。
看看unreal是如何做的,展开它的rendercommand宏他的成员变量用来存放这个蒙皮信息矩阵的是一个array,构造实例的时候,会把主线的array传到构造函数,来构造这个rendercommand实例,可能你一个地方这么用还好,如果大量这么用,数组之间的构造赋值,开辟空间,等这个command执行完后要析构,数组释放等等,这里会牺牲很多速度。
可以看到使用这个东西的时候,存在很多让人纠结的地方,就现在may引擎,你把那里代码扔到渲染线程里面,这个你要思考好多,还要主线线程安全,更改到多线程渲染,按照unreal方式是一个很耗费工程。
好的多线程的设计,应该是在引擎层和D3D层再有一层,这一层让使用引擎处理渲染问题时候去规避这些多线程风险。
这个中间层,封装所有D3D,然后涉及到多线程的问题都在这一层处理,同时让引擎很容易就集成这种效果。
LowlevelrenderCommand,只是相对于highlevelrendercommand提出的,我把只封装D3D函数并且不涉及其他的都叫做lowlevelrendercommand,那么其余都叫做highlevelrendercommand。
有些时候我需要一些组合D3D函数来达到效果,比如设置一个rendertarget,通常调用这个D3D函数的人,不会只调用它,还会先get当前rendertarget,保存住,然后在endrendertarget的时候你还要恢复回去,这个时候你要多个D3D函数集合成来达到。
这样看来unreal里面基本上都是highlevel的,至于D3D资源的创建,主线程创建就可以了,因为采用LowlevelrenderCommand你只有创建出来才会调用它,传递给渲染线程(这里还有资源创建和使用多线程问题,后面会详细说明)。
如果只用上面的方法,集成到引擎中,基本不需要架构修改,只需要添加代码即可,但用Lowlevelrendercommand不能处理所有问题,就是D3D资源的LOCK问题,这个东西要和引擎层打交道,引擎更新的数据,要传到D3D资源LOCK的buffer中,如果想让使用引擎者对于lock是安全的,你就要封装lock里面再做多线程安全处理。
有2种方法
1.lock的时候挂起渲染线程,unlock的时候唤醒渲染线程
2.创建双D3D资源。
无论那种方法,只有资源是动态资源才会出现这种情况,如果开启多线程渲染,并且是动态资源,中间层都能处理。
第一种方法实现最简单,但效率很低,对于粒子等大规模这种lock。
当然你可以分类,统一集中一起处理,把所有动态资源都放到一起,这就增加了管理成本,本身挂起渲染线程就已经减少了效率,在这个过程之间,你的渲染线程不会进行任何数据处理,只能等到unlock结束后。
第二种方法封装d3d资源的时候创建双D3D资源,每帧同步交换2个buffer,经验来讲这里占用多处理的存储空间不会是你内存瓶颈,速度还很快。
第一种方法如果把动态资源分类的话,实现起来最简单的,虽然有速度损失,但也比单个线程跑快。
分类的话,就需要有一个管理这些资源的系统。
第二种方法需要在封装D3D资源设计上做点考究了,架构上必然要去修改。
速度块,改动小的方法,就是用highlevelrendercommand,但这打破所有的都有一个中间层来规避线程安全的问题,引擎只需要把lock相关的代码封装进去就可以。
到现在为止还有一个问题没有解决,就是rendercommand线程无关的数据存放问题,unreal是用类成员变量来弄的,上面说过这个确实有很多问题。
所以再管理command同时再去管理一个内存分配问题,这个空间是事先分配好的,不够可以自动增长,无论rendercommand还是在这个过程中涉及到线程安全的都在这里面分配,每一个处理数据buffer都包含以一个这样的空间。
还是