(1);
}
}
elseSleep
(1);
}
上面这个消息循环不是我捏造出来的,是一个较知名的游戏引擎的核心消息循环,它使用了一个死循环do{dt=timeGetTime()-t0;}while(dt<1);来精确延时,确实这是一种非常不错的想法,这里不去评论写的代码易读性的问题,首先让我们来看看这样做的性能问题,在实际使用这个引擎的过程中,我发现即使游戏是不活动的,即场景相对静止,也没有用户输入,甚至被切换到后台的情况下,这个引擎的CPU占用率也至少在80%以上,有时甚至一直是100%,造成用户机器出现非常卡的问题,其实很多时候CPU都被浪费在这个无聊的死循环上了。
然后让我们来看看它的扩展性问题,这里我们看到一个貌似处理鼠标消息的函数_UpdateMouse(),这个函数看上去是主动工作的,这样的缺点就是如果鼠标没有任何动作时,这个主动查询几乎是浪费掉的,实际上我们更希望一种优雅的被动方式来响应鼠标或键盘的输入,当然不是靠那个低效率的鼠标键盘消息。
说道要定时,很多人会立刻想到说,这有何难,设置个WM_TIMER消息不就完了?
据非正式统计,还没有游戏引擎消息循环是靠WM_TIMER运作的。
当然靠WM_TIMER保持消息循环的“活度”是可以的,但是这种方式因为Windows平台定义的原因,永远是最后一个被放到消息队列中的消息,而且其精度是很糟糕的,已经被很多其他的文章炮轰的体无完肤了,这里我就不再啰嗦叙说它的是是非非了。
ok,好了到这里我们发现以上方式都不是最佳的方案,一个小小的消息循环,其实细想起来还是比较头疼的,更是要花费一番思量。
所谓“众里寻他千XX,暮然回首,那人却在灯火阑珊处!
”,其实上面的一些考虑过程只是考虑消息循环结构的开始。
接下来让我们深入一些考虑这个问题。
现代的CPU起码都是工作在纳秒级的设备,因为动辄几个G的高频率,并且支持每时钟周期执行n条指令,这样算来一个纳秒内CPU大概可以执行好几条指令,而刷新一帧游戏场景界面的频率顶多就是100fps,再高实际也没有什么意义了(你要搞《阿凡达》那样的3D视觉就另当别论了,呵呵呵),也就是10ms左右渲染一帧画面,这个时间内,如果按照1纳秒执行一条指令的速度算,那么一帧之内CPU大概可以执行10M条指令的水平,这样一来如果让CPU的时间白白浪费在空转的死循环上,显然是一种巨大的浪费。
而且我们知道现在的游戏引擎不仅仅需要处理3D/2D的画面,还需要处理物理效果、人工智能、网络、键鼠输入、游戏手柄输入、音效等等,处理这么多任务,10M指令已经显的很力不从心了,这时显然需要一种更高效更合理的安排,让消息循环真正的做“有用功”。
当然现在的CPU基本都是多核的了,更可以预见未来是多核的时代,你也许会想用多线程不就可以了吗?
真是这样嘛?
其实简单一盘算,多核的效果在理想情况下,无非就是在一帧的时间内多了n个10M指令级的处理能力而已(目前n最大貌似=8),这样带来的好处也只是CPU个数的倍数而已,并不是质的飞跃,而真正有效利用好这些指令才是我们提高性能的真正有效途径。
还有另一个好消息就是DX11中已经可以进行多线程渲染了,这也是提高性能的一个方法。
不管怎么样,引擎总是至少需要一个主线程来总控所有的这些逻辑处理,那么一个消息循环究竟要考虑哪些东西呢?
答案其实已经有了,第一个要务就是精确定时、第二就是响应玩家输入、第三就是响应网络消息、第四就是做场景变换(人工智能、物理变换),第五就是渲染,还有就是处理音效。
要做到这些那么就要一个既能响应消息、又要能与内核同步、还能等待对象状态改变的函数登场了,那就是——MsgWaitForMultipleObjects,这个函数很多人已经不陌生了,那么我也就不多啰嗦了,详细的说明可以直接去查阅MSDN,下面我们就消息循环的几个要素展开看具体的如何实现:
一、定时:
谈到定时,会立即联想到SetTimer、timeGetTime、甚至QueryPerformanceCounter等等,其中除了SetTimer以外,其它的方式都是要主动去查询或计算时间,显然这会引起一些不必要的麻烦,而SetTimer就是靠WM_TIMER工作的,性能就不敢恭维了,那么有没有其它被动工作,而定时又比较精确的方法呢?
答案就是CreateWaitableTimer,这个函数会创建一个定时器内核对象,精度非常的高,传说中可以定时100纳秒级的时间,这已经是足够了,其实只要在10ms的渲染周期之内的定时值就足够了,场景变换也无非到这个时间精度就够了,因为再小的时间精度变换,都没有渲染成实际的帧画面,那么也就没有什么意义了。
具体的使用就可以看下面的示例代码了:
HANDLEphWait=CreateWaitableTimer(NULL,FALSE,NULL);
LARGE_INTEGERliDueTime.QuadPart=-1i64;//1秒后开始计时
SetWaitableTimer(phWait,&liDueTime,40,NULL,NULL,0);//40ms的周期
DWORDdwRet=0;
BOOLbExit=FALSE;
while(!
bExit)
{
dwRet=:
:
MsgWaitForMultipleObjects(1,phWait,FALSE,INFINITE,QS_ALLINPUT);
switch(dwRet-WAIT_OBJECT_0)
{
case1:
{//计时器时间到
SceneFun();//场景变换
Render();//渲染
}
break;
case1+1:
{//处理消息
while(:
:
PeekMessage(&WndMsg,NULL,0,0,PM_REMOVE))
{
if(WM_QUIT!
=WndMsg.message)
{
:
:
TranslateMessage(&WndMsg);
:
:
DispatchMessage(&WndMsg);
}
else
{
bExit=TRUE;
}
}
}
break;
default:
break;
}
}
上面的就是加上了精确定时改造后的消息循环了,因为MsgWaitForMultipleObjects是个内核同步函数,所以它既可以等待消息也可以等待内核对象,整个的主线程消息循环就变成了优雅的被动工作方式,不用担心那个定时器会像WM_TIMER一样糟糕,它精确的会让你吃惊,上面的例子中设定了40ms刷新一次场景,那么大概就是25fps,这是个很低的帧数,你可以把40这个常量定义成变量,然后根据需要动态调整这个值。
也可以为此设计一个自动动态计算延时值的函数来动态调整每帧之后的延时值,当然这需要依赖于当前渲染场景时间周期的一个统计平均值,然后在每次渲染结束后再调用一下SetWaitableTimer。
最后跟其它的内核对象一样,CreateWaitableTimer返回的句柄再不需要的时候就可以用CloseHandle关闭。
CreateWaitableTimer的第二个参数,表示是否手动Reset一下定时器内核对象的状态,传入FALSE表示要自动Reset。
有些时候,这并不是很好,尤其是复杂场景变换过程中有可能会耗费比较长的时间,这时,可以将这个值设定为TRUE,由我们自己在需要的时候重置这个标志,并进入下一个循环等待状态,不然会出现,前一帧还没有渲染完,而后一个定时的时间又到了,出现不必要的循环后跳帧的状态。
如果是手动的重置的话,就需要再次调用SetWaitableTimer将对象置为无信号状态。
二、输入响应:
对于输入来说,有一些引擎直接就是用了Windows键鼠消息,当然对于一般的游戏来说这足够了,但是对于一些需要有微操作或者需要极高用户体验的场合,Windows消息就显得反应迟钝了,另外如果要使用游戏手柄等其他设备时Windows消息就更是无能为力了,这时必须使用DirectInput,如其名一样“直接输入”,没有比这更直接的方式了,很多时候很多程序员对DInput很迷惑,只是知道它性能高,但是为什么比Windows消息性能高,就真的不知所以然了,其实根本原因是因为,在Windows内部,键鼠产生的消息是由驱动先放到系统内核的输入消息队列上,然后再由系统内核线程把它取出来,丢到对应窗口线程的消息队列中,最后才由消息循环取出来,然后在Dispatch到真正响应消息的窗口的窗口过程中,这个过程由过程就可以看出是非常的低效的,而DInput则绕开了这些繁琐的过程,让程序具备了直接访问硬件驱动数据的能力,即“直接输入”,直接拿到输入数据,这就是为什么DInput要比Windows消息快的原因。
传统的教程中,多是使用主动调用IDirectInputDevice8函数的GetDeviceData方法得到输入的数据,这种方式如前所述,不是很合理的一种方式,也会造成一定的浪费,因为输入最终是人产生的,人的反应再快也是没有现在CPU的运算速度快的,让一个快速工作的CPU不断的访问一个低速产生数据的设备,这显然是一种不太明智的方法。
这种时候,可以通过创建一个Event内核对象的方法,来让输入产生一个通知,然后再由消息循环去取数据。
具体的例子如下:
LPDIRECTINPUTDEVICE8pIKeyboard=NULL;
LPDIRECTINPUTDEVICE8pIMouse=NULL;
LPDIRECTINPUTDEVICE8pIJoystick=NULL;
//初始化并创建DirectInput
..............
HANDLEhMouseEvent=CreateEvent(NULL,FALSE,FALSE,NULL);
HANDLEhKeyboardEvent=CreateEvent(NULL,FALSE,FALSE,NULL);
HANDLEhJoystickEvent=CreateEvent(NULL,FALSE,FALSE,NULL);
HANDLEah[3]={hMouseEvent,hKeyboardEvent,hJoystickEvent};
//为DirectInput设备设定Event对象
pIKeyboard->SetEventNotification(hKeyboardEvent);
pIMouse->SetEventNotification(hMouseEvent);
pIJoystick->SetEventNotification(hKeyboardEvent);
while(TRUE)
{
dwResult=MsgWaitForMultipleObjects(3,ah,FALSE,INFINITE,QS_ALLINPUT);
switch(dwResult)
{
caseWAIT_OBJECT_0:
//鼠标事件
ProcessMouseEvent1();
break;
caseWAIT_OBJECT_0+1:
//键盘事件
ProcessKeyboardEvent2();
break;
caseWAIT_OBJECT_0+2:
//游戏手柄事件
ProcessJoystickEvent2();
break;
caseWAIT_OBJECT_0+3:
//Windows消息
while(PeekMessage(&msg,NULL,0,0,PM_REMOVE))
{
if(msg.message==WM_QUIT)
{
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
break;
default:
break;
}
}
上面的例子中利用了MsgWaitForMultipleObjects可以等待多个内核对象的特性,并利用IDirectInputDevice8:
:
SetEventNotification接口函数,为设备设置事件对象,这样一来,消息循环就只在真的有输入时才去响应输入,而且也是直接读取输入,所以性能上并没有损失,因为Event内核对象和等待函数的工作性能要比Windows消息方式的工作性能至少高几个数量级,所以不用担心等待函数会消极怠工。
甚至在WindowsNT以上的内核中,专门针对等待函数对系统线程调度函数做了各种优化,几乎可以认为等待函数与内核对象的状态改变是同时发生的一件事情,或者理解为,对象状态一变化,等待的线程就被立即执行了,这也是为什么有些时候称这些对象为“内核同步对象”的原因之一。
因此上面的方案比之主动去取数据的方式是没有任何性能损失的,相反这种被动的工作方式大量的节约了无效轮询输入设备的操作,充分节约了CPU指令。
三、网络消息响应:
对于一个一般的游戏引擎来说,网络不是一个必选项,之所以还要再讨论下这个话题,是因为现在国内貌似只有网游赚钱了,所以不带网络功能的引擎,可以认为是一个被阉割的引擎,使用的时候还要自己考虑网络部分怎么整合,实在是不太方便。
讨论这一话题的另一原因是因为这里假设网络部分是在完全不同的线程中运行,甚至是在另一个CPU内核上运行,以后引擎的发展也会朝着真正多线程的方向发展,因此讨论下线程间的这种同步与消息循环整合的问题有着一种十分现实的意义。
在本人博客其它几篇关于IOCP的文章中已经讨论了一些网络部分的内容,这里就不在赘述了,只是假设这里的网络模块也使用IOCP单线程网络模块的模式。
首先网络模块有一个自定义的网络消息队列,当网络模块收到消息时,先将消息包如这个队列,然后将两个线程都公用的Event句柄置为有信号状态,而主消息循环等到这个状态之后就立刻从这个网络消息队列中读取消息包,依次处理之。
大概的模型代码如下:
//全局变量,在主线程启动时初始化
HANDLEg_hNetEvent=NULL;
//网络模块线程
DWORDWINAPIIOCP_ThreadProc(LPVOIDlpParameter)
{
......
SetEvent(g_hNetEvent);
......
}
//主消息循环
g_hNetEvent=CreateEvent(NULL,FALSE,FALSE,NULL);
CreateNet();//创建网络模块并启动网络线程
DWORDdwRet=0;
BOOLbExit=FALSE;
while(!
bExit)
{
dwRet=:
:
MsgWaitForMultipleObjects(1,&g_hNetEvent
FALSE,INFINITE,QS_ALLINPUT);
switch(dwRet-WAIT_OBJECT_0)
{
case1:
{//网络消息包
PopMsg();//弹出网络消息包
ProcessMsg();//处理网络消息
}
break;
case1+1:
{//处理Windows消息
while(:
:
PeekMessage(&WndMsg,NULL,0,0,PM_REMOVE))
{
if(WM_QUIT!
=WndMsg.message)
{
:
:
TranslateMessage(&WndMsg);
:
:
DispatchMessage(&WndMsg);
}
else
{
bExit=TRUE;
}
}
}
break;
default:
break;
}
}
这样两个线程的工作职责非常明确,一个线程响应网络传输,而主线程负责总控游戏住消息循环,这样就比较好的解决了其它线程与主线程沟通的问题。
当然,网络线程可以做的更多,比如网络线程接到网络消息后,先不入队列,而是先判断是什么网络消息,如果是更新场景的消息,那么就直接去更新场景,这样就可以部分减轻主消息循环更新场景处理的压力,但是这样做的缺点就是场景必须能够适应多线程的并发处理,这对于场景逻辑结构的设计是一个不小的挑战。
好在现在已经有很多并发类型的数据结构可以借鉴利用了,有些结构